mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Merge branch '1.2.5.6'
This commit is contained in:
@@ -98,3 +98,8 @@ security/
|
||||
|
||||
test_pipeline_archive_source.3mf
|
||||
test_pipeline_run_1.3mf
|
||||
|
||||
# Python coverage artifacts
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
+62
-6
@@ -2,6 +2,68 @@
|
||||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [1.2.5.6] - 2026-09-24
|
||||
|
||||
### Added
|
||||
- **Swedish (sv) is now a supported interface language (#3052, requested and contributed by @AntonPalmqvist in [PR #3062](https://github.com/maziggy/bambuddy/pull/3062))** — Adds `sv` as the fifteenth locale, listed as "Svenska" in the language picker. It arrives in parity with the reference locale rather than as a file to be finished afterwards: all 6302 leaves present, structure and key order matching `en.ts`, and every `{{placeholder}}` set intact, which is what the parity gate checks before a locale is allowed to ship. 156 of those leaves are identical to English, and each is enumerated by value in a 74-entry `SV_COGNATES` allow-list in `check-i18n-parity.mjs` — the same shape the other thirteen locales use, so the exemption is an enumerated translator decision rather than a blanket skip for the language. They are the strings with nothing in them to translate (`{{weight}}g`, `Hex: #{{hex}}`, `{{filament}} @ {{temp}}°C`), the product names Bambu and Orca ship untranslated, and the technical UI vocabulary Swedish takes verbatim from English — filament, status, extruder, timelapse, firmware, proxy, pipeline, diameter. Parity green at 6302 leaves across all 15 locales.
|
||||
|
||||
### Changed
|
||||
- **The schema no longer contains a cycle that `metadata.sorted_tables` cannot sort** — `print_archives.library_file_id` → `library_files.folder_id` → `library_folders.archive_id` → back to `print_archives`: three nullable `SET NULL` links, each reasonable alone, that together made a loop SQLAlchemy answered with a sort it could not make and a warning on every backup and every restore. The order it returned could place a child before its parent, which is what once imported `library_files` ahead of `library_folders` and killed a restore on a foreign key violation — the restore has since stopped depending on that order by stripping every foreign key before importing and adding them back afterwards, but the backup export sorts the same way, and the warning ends with "may raise an error in a future release", which would break backup and restore on the same upgrade. One edge of the loop is now marked `use_alter`, which removes it from the sort graph without removing the constraint from the database: PostgreSQL emits it as `ALTER TABLE ... ADD CONSTRAINT`, as it already did for every constraint on those three tables, and SQLite — which reports no ALTER support — inlines it into `CREATE TABLE` exactly as before, so `ON DELETE SET NULL` keeps working on both. `use_alter` requires a name, so new databases carry `fk_library_folders_archive_id` where existing ones keep PostgreSQL's generated `library_folders_archive_id_fkey`; nothing refers to either by name. Pinned by a test that fails if a new foreign key closes a loop again, and by one asserting the key still exists on SQLite — if that inlining ever stopped, deleting an archive would leave a dangling `library_folders.archive_id` instead of nulling it, silently.
|
||||
- **The MakerWorld integration is now a model-provider package, so a second model site is an implementation rather than a second copy of the feature ([PR #2845](https://github.com/maziggy/bambuddy/pull/2845) by @pascalheidmann)** — `services/makerworld.py` was a single 635-line module that owned everything from URL parsing to the presigned S3 download, and the routes called it by name, so Thingiverse or Printables would have meant a second route file beside the first. It is now an interface: a `ModelProvider` descriptor carrying identity, host patterns, the credentials it needs, its library folder, its permissions and its SSRF allowlists, plus a per-request `ProviderService` that does the transport — with MakerWorld as the first implementation and a registry that hands a pasted URL to whichever provider claims it. Nothing about the MakerWorld flow changes: the endpoints and their request and response shapes are unchanged, the new `source_type` field defaults to `makerworld` so existing callers send exactly what they sent before, and the transport moved verbatim — thirteen of the eighteen functions are byte-identical by AST comparison, including the CDN allowlist, the 200 MB download cap and the certifi-pinned TLS context that keeps S3 downloads working on Windows. Four things did change. Bambu Cloud credential storage moved out of `routes/cloud.py` into `services/bambu_cloud_credentials.py`, because a feature package that has to import a route module to read a stored token is a dependency pointing the wrong way; the cloud endpoints themselves did not move. An import naming a provider that is not registered is now refused before anything happens, where it used to create the destination folder on its way to failing. "Your Bambu sign-in expired" is now an explicit flag the provider sets rather than something inferred from whether an error message happened to be present, so a network failure or a rate limit can no longer be reported to you as an expired sign-in. And each request is authorised against the permission of the provider it actually named instead of MakerWorld's — latent while MakerWorld is the only one registered, wrong on the day it is not.
|
||||
- **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
|
||||
|
||||
### Fixed
|
||||
- **An AMS that reports no humidity percentage no longer shows the drop index as one (#3140, reported by @Sawtaytoes)** — Bambu sends two humidity fields that are not the same quantity: `humidity_raw` is relative humidity in percent, and `humidity` is the 1-5 drop index, which runs the other way — a high index means dry where a high percentage means wet. Bambuddy used the index whenever no percentage arrived, so a unit sending only the index rendered as "2%" in the green band while being the second-wettest of the five steps, charted an average of index values as a percentage, and could never cross a humidity alarm or auto-drying threshold, since no index reaches one. Such a unit now reports no humidity at all: the card hides the water-drop indicator, the history chart leaves a gap, and the alarm and auto-drying skip the unit instead of reading it as permanently bone dry. Temperature is recorded and alarmed on as before. No supported printer is known to be affected — the report came from an install running X1Plus, which Bambuddy does not support — and printers that send a percentage keep it, including two readings that previously fell through to the index because they would not parse as a whole number: one with a decimal point, and `38.0`. A unit that sends the index and no percentage now says so once in the log, with its firmware versions requested, so a supported printer that turns out to do this is visible rather than silently blank. Three smaller faults in the same paths went with it: a humidity of exactly 0% was stored as NULL when the firmware sent it as a number, a history window averaging exactly 0 reported no average at all while the minimum and maximum beside it reported 0, and a `humidity_raw` that was not a number at all aborted the whole recording pass for every printer, not just the one that sent it.
|
||||
- **Two Bambuddy instances could not have their CA certificates trusted at the same time (#3014, reported by @Steven-Pierce)** — a slicer holding the CA of two Bambuddy installs failed to connect to one of them, with the same generic `code=-1` an install whose CA was never imported gives. Each CA worked on its own; together, one stopped. Every install signed its virtual printers with an authority named exactly `CN=Virtual Printer CA`, and a slicer's trust store is a flat list of certificates that OpenSSL resolves by subject name: it takes the first authority whose name matches the certificate in front of it and fails the chain when that authority turns out not to have signed it, rather than trying the next match. Whichever of the two landed second in the file therefore lost, decided by nothing more than the order they were appended in. A newly generated CA now carries a per-install suffix taken from its own key — `CN=Virtual Printer CA D55808BE` — and publishes that key identifier, which the printer certificate then points back at, so the two are told apart by name and each chain resolves to the authority that really signed it. Nothing has to be re-imported on upgrade: an install that already has a CA keeps it untouched, and the printer certificates signed by it keep exactly the shape they have today. The collision goes away as soon as one of the two CAs is newer than this release — a second install set up after upgrading, or an existing one whose `bbl_ca.crt` and `bbl_ca.key` are deleted from `virtual_printer/certs/` so a fresh CA is generated, which does mean importing that one into the slicer again.
|
||||
- **ntfy per-event priorities were set, stored, and never sent (#3139, reported by @Thomansky)** — a per-event priority (#990) never reached ntfy: every notification arrived at the server's default, whatever the dialog showed. The two halves of the feature disagreed about the key. The dialog builds its rows from the provider's event toggles and stores the map under those names — `on_print_complete`, `on_print_failed` — while every sender is called with the bare event name, `print_complete`, so the lookup missed for all 18 events the dialog offers. It looked healthy from both ends: the stored config held exactly the priorities that were set, and the tests were green because they called the ntfy sender directly with the prefixed name, the one spelling the running system never produces. The lookup now accepts either spelling, so existing configurations keep working and nothing has to be migrated. The tests use the bare form, and one of them now runs from a finished print through to the outgoing request, which is the assertion that would have caught this. On upgrade, priorities that have been sitting in the database inertly start being honoured, so an event mapped to **Min** or **Low** will now arrive quieter than it did yesterday — that is the setting taking effect, not alerts going missing. The daily digest is unchanged and still goes out at the server default: it summarises several events at once, and the dialog offers no priority for it.
|
||||
- **Restoring a backup into a different version of Bambuddy failed, and the failure took the live database with it** — A backup carries the schema of the install that made it, and the PostgreSQL restore path does not use that schema: it drops every table, recreates them from the running process's ORM, and inserts the backup's columns into the result. Any NOT NULL column the running version has and the backup does not then arrives with nothing to put in it, and a model-side `default=` cannot help, because SQLAlchemy applies one to ORM and Core inserts and never to the raw `text()` SQL this import builds — `create_all` emits no DDL default for it either. A backup made after `user_wallets.currency` was dropped in #3123, restored onto an install that still had the column, died on `null value in column "currency"` with the drop already committed: the restore's first transaction wipes the database, so the install was left with an empty schema, the previous data gone, and the MFA key file already replaced by the backup's — whatever had survived would have been encrypted under a key that no longer matched. The import now fills such a column from the model's default (evaluating a callable one), leaves a column with a server default to the database rather than second-guessing a timestamp it is better placed to produce, and ignores a nullable one exactly as before. A column that genuinely cannot be filled is refused **before anything is touched** — before services are paused, before the key file is written, before the first `DROP` — and the same applies to a `bambuddy.db` that is not a readable SQLite database at all, which previously was discovered only by the import that had already dropped the tables. Backups now carry a `manifest.json` naming the version that wrote them, so the refusal reads "this backup was made by X, this install runs Y" rather than a list of column names; a backup taken before the manifest existed restores exactly as it did, with the version simply unknown. The refusal is an HTTP 400, and the frontend was dropping its body on the floor — a refused restore is an `HTTPException`, so its `{detail}` is not the `{success, message}` a successful one returns, and the modal raised an empty error toast — so the reason now reaches the operator who has to act on it. A SQLite install is unaffected throughout: it restores by copying the backup's pages, schema included, and migrates forward afterwards.
|
||||
- **Slicing a plate of many copies of one part could take the server down (#3135, reported by @TheUltimateC0der)** — the reporter sliced a plate of 25 small Gridfinity bins and Bambuddy was OOM-killed straight after the slice finished, taking the slice job with it. After every slice Bambuddy draws the plate thumbnail itself, because the slicer's command-line mode leaves it out, and it read the model with trimesh. trimesh mishandles the layout Bambu Studio and OrcaSlicer save projects in, where each copy is a reference into a shared model file: it read that file again for every reference and kept every copy of its triangles, so 25 copies of a part arrived as 625, and 25 bins of 10,000 triangles each became 6.4 million. Drawing that took 54 seconds and 8.4 GB, and it ran on the server's main loop, so the web UI and every printer connection stalled for as long. A project whose model file holds several parts had a second symptom: each placed part pulled in every other part from the same file, so its thumbnail drew parts in places they were never put. The plate is now read directly, each model file once and each part once however many times it is placed, and a large plate is simplified per part to a fixed triangle budget before its copies are laid out. The reporter's plate now renders in about 2.4 seconds at under 400 MB, and the heaviest real projects measured went from 48 seconds and 9.3 GB to 20 seconds and 0.8 GB. The thumbnail is drawn off the main loop, and a plate still too large after simplifying is left without one instead of risking the server.
|
||||
- **Moving a queued job from "Any P2S" to one P2S threw away its filament colour (#3133, reported by @bgrr74)** — a job queued for any printer of a model can print in a different filament from the one it was sliced with: brown in the 3MF, Bone White on the spool. Editing that job and switching it to a specific printer of the same model reset the choice, and the job went back to brown. The print dialog cleared overrides whenever the target model changed, and "Specific Printer" empties the target model, so the switch counted as a change. Clearing would not have been the whole problem anyway: in specific-printer mode the dialog matched trays against the 3MF's colours, never sent the override, and left the old one on the queue item, where it contradicted the trays the job would actually use. Now the override is kept through the switch, and the chosen printer's trays are matched against the requested filament, so Bone White picks the Bone White spool, and a printer without one shows the usual colour-mismatch warning for Bone White instead of quietly settling on brown. The job keeps the override on the queue item, so if the dialog could not resolve every tray, the scheduler's match at dispatch looks for the same colour. Moving the job to a different printer model, or picking a different plate, still clears the override, as before. A printer's queue card and badge no longer hide a job assigned to that printer because its override colour is not loaded. That check is for picking among printers of a model, which a job for one printer has already done. The same goes for a virtual printer's force-colour jobs sent to a specific printer, which the card used to hide the same way even though the scheduler dispatched them. Saving a virtual-printer job in the print dialog now keeps the PLA-variant pin (Basic, Matte or Silk) its force-colour overrides carry. Editing one queued for any printer of a model used to strip it.
|
||||
- **Asking for 25 copies across two printer models queued exactly one (#3101, reported by @Leander-Vh)** — the reporter sliced one project for two printer types, selected both files in the File Manager, set the count and pressed Print. One item printed, the queue was empty behind it, and the Batches tab held nothing. Two features had met badly. Cross-model alternatives (#671) put several sliced files behind one queue slot, and a multi-plate file (#342) moves the run count out of the modal's single Quantity field onto a stepper beside each plate. The cross-model submit reads the field, not the steppers — and on a multi-plate file that field is not rendered at all, so it stayed at its initial 1 with nothing on screen able to change it. The modal read "19 runs in total" directly above a button that queued one. Cross-model no longer borrows the per-plate steppers: such a job picks its plate per candidate, down in the alternatives list, so there is only ever one number to give and the ordinary Quantity field is back. The plate selector above it is gone in that mode for the same reason — its choice never reached the request. It only decided which plate the filament panel described, which is why picking plate 3 for a candidate while plate 1 stayed ticked above it produced overrides computed from a plate the job would not print; that panel now follows the first candidate's own plate. The backend needed nothing for the count itself — it has always created one item per copy and given each its own candidate rows, so the same request now produces 25 jobs, each free to land on whichever of the two models frees up first. It did need a name: a cross-model job carries neither an archive nor a library file, because the candidates are the files, so both branches that name a batch missed and the order the reporter went looking for would have appeared in the Batches tab as "Batch". It is now named after its first candidate, the same file the print dialog names the job after.
|
||||
- **A print from Bambu Studio archived under the name `plate_1` instead of its own (#3126, reported by @alex-2000-ac-ghetto)** — the reporter's X2D archived Bambu Studio prints with no thumbnail, no filament total and no layer count, while the same project sent from OrcaSlicer archived in full. Two separate things were happening. The first is the known internal-storage limitation: Bambu Studio filed the sliced file on the printer's eMMC (`"url": "brtc://emmc/..."`), which FTPS on port 990 does not serve, and the X2D does this like the H2 series and the P2S. Bambuddy's bounded probe then found a file of exactly that name on the card and downloaded it — but it was an earlier slice of the same project, plate 4, while the running print was plate 1. The guard added in #1204 caught the contradiction and refused it, which is right: archiving plate 4's thumbnail, filament and cost against this print is the swap #2957 removed. What it did next was not. Having rejected the file, it asked `swap_plate_suffix` for a name with the correct plate number and blanked the print's name entirely whenever that came back `None` — but `None` covers a name that carries no `- Plate N` suffix at all, and such a name holds no stale plate number to be wrong about. So a single-plate project lost the name Bambuddy was holding and the row fell through to the gcode_file path, titled `plate_1`. #1204's own premise is consecutive plates *of the same model*, so the project part of a lagging name is right either way; it is now kept as the archive's title, and only a suffix that really does point at the wrong plate is rewritten. Kept as the title and nothing else, though: a name the guard has just watched fetch another plate's 3MF still gets disowned everywhere a file is looked up by name. That includes the `_active_prints` keys, and it matters — the cover endpoint downloads the running print's 3MF under that same name for a thumbnail and offers the bytes to the fallback-recovery path, which checks that a candidate is a readable 3MF but never which plate it holds. Registering the row under the rejected name would have let it be filled in with the exact file this branch had just discarded. The archive is still thumbnail-less — nothing can read that eMMC file — but it carries the print's name again. Separately, the Archives banner and the connection diagnostic named only "H2-series and P2S" as the models that do this, in all 14 languages; an X2D owner reading either was told the explanation did not apply to them. All three now name the X2D too.
|
||||
- **Linking a tag another spool already carries gave an answer nothing could act on, and a duplicate broke the request outright (#3110, reported by @Niko11111)** — the built-in inventory refused with "Tag UID already linked to another active spool", naming nobody, although the route had just loaded the conflicting spool row and was holding it when it wrote that sentence. Spoolman mode did name the spool, but only inside a different English sentence. Same operation, two answers, neither machine-readable, so a client had to parse prose to learn which spool to look at — and could only do it in one of the two inventory modes. Both now raise one structured 409, `{"code": "tag_already_linked", "message": ..., "spool_id": N, "field": "tag_uid" | "tray_uuid"}`, the shape Bambuddy already answers `insufficient_filament` and `printer_connection_failed` with. The SpoolBuddy kiosk reads it: a refused link showed a flat "Failed to assign spool" and now names the spool holding the tag, so the operator can walk to that spool instead of rescanning one that cannot link. The other half was worse than untidy. Nothing stops two active spools from carrying one tag — neither column has a unique index, `PATCH /inventory/spools/{id}` writes both with no conflict check, and `POST /inventory/spools/bulk` copies a single payload, tag included, into every row it creates — and the conflict lookup read its result with `scalar_one_or_none()`, which raises when two rows come back. That exception escaped the route into the authentication middleware’s fail-closed handler, so the caller was told "Authentication service temporarily unavailable" for a request that had nothing to do with authentication. The lookup is now ordered and takes the first row, the way the by-tag lookup earlier in the same file always has, and a duplicate yields a 409 naming the lowest id.
|
||||
- **"Clear RFID Tag" was permanently greyed out for a spool linked by its Bambu tray UUID (#3109, reported by @Niko11111)** — the button gated on `tag_uid` alone, and a spool linked by its 32-character tray UUID carries none: Bambuddy splits a stored tag by length, so a 32-char value becomes `tray_uuid` and `tag_uid` stays empty. In Spoolman mode that is every Bambu Lab spool synced from the AMS — on the reporter's instance, 35 of the 39 tagged spools, not one of which could have its tag cleared from the dialog. Everything around the button already treated those spools as tagged: the Tag ID column renders whichever identifier is present, and the payload the button sends clears both fields. Only the one condition disagreed. Either identifier now enables it, and clearing still removes both. The documented workaround — editing `extra.tag` in Spoolman's own interface — is no longer needed.
|
||||
- **Assigning a spool to a slot holding a non-Bambu filament configured nothing, and could delete the assignment afterwards (#3084, reported by @anthonyma94; #3100, reported by @Sawtaytoes)** — swap a Bambu spool for one the AMS cannot read and Assign Spool published no `ams_filament_setting` at all: the printer kept showing `?` on its own screen and in the slicer, and only Configure, which publishes unconditionally, put anything there. Three backend decisions were reading the tray's `state` field to answer "is there a spool in this slot", and `state` cannot carry that. It is firmware-variant — the A1 Mini BMCU and the P1S Standard AMS report 3 for a loaded slot and never emit 11, and an AMS-HT reports a *loaded* tray as 9 because it does not feed into a shared buffer (#2594) — and it is also partly Bambuddy's own writing: the empty-slot cleanup stamps `state = 9` on every slot whose `tray_exist_bits` bit is 0, and when the bit comes back it refreshes only the `exists` annotation beside it. Either way a slot the firmware said was full sat in the cache reading `exists=True, state=9` until something configured it, and the deferred configuration that is supposed to rescue a pre-assigned slot could not fire either, because its own "has a spool appeared" test was the same 9. That is the deadlock removed from the assign path in #1322, still in place one step further along. The same 9 is what deleted the assignments in #3100: the replay never fired, so the row kept the empty fingerprint it was stored with, and the first tray report that named a filament was read as a spool swap and unlinked. Every one of the three now reads `tray_exist_bits` first, which is the bitmask firmware actually answers this question with and the one the printer card has drawn its `?` from since #2527; `state` remains the fallback for the external spool and anything else whose payload carries no presence bit. A blank tray report from a slot the bit calls occupied no longer unlinks anything either — off a print as well as during one — because a filament the AMS cannot identify is not a filament that was removed.
|
||||
- **The Finance page showed euros whatever currency the install was set to (#3123)** — it was the only surface in Bambuddy that read its currency from a data row (`user_wallets.currency`) rather than the `currency` setting, and it fell back to EUR where every other page falls back to USD. One variable drives every amount on that page, so the personal balance, the cost-center budgets and the whole transaction list were all wrong together. The backend was the other half of it: of the four places that settle on a currency, three wrote a hardcoded `"EUR"` — the wallet the API mints on demand, the wallet a print charge mints when none exists, and the balance returned for a user with no wallet row at all. The page now takes the configured currency from `/settings/ui-flags`, which is readable by anyone who can see Finance, and the API reports the setting. The `user_wallets.currency` column is gone altogether — an install has one currency and nothing here converts between them, so a per-wallet copy could only ever drift from the setting; a startup migration drops it from existing databases on both SQLite and PostgreSQL. Saving settings now also refreshes those flags, which nothing invalidated before.
|
||||
- **On Windows and macOS the Virtual Printer's bind dropdown offered one entry per network adapter, not one per IP address (#3121, reported by @SJB-OLVG)** — each enabled virtual printer needs its own address, and the documented way to get several is to add secondary IPs to the adapter you already have. Linux reads those back through `ip -j addr show`, which reports every address; Windows and macOS have no `ip` command and fell through to an enumeration that stopped at the first IPv4 of each adapter. A Windows host with three addresses on one NIC therefore offered exactly one bind target, and creating a second virtual printer could only fail with "Bind IP ... is already in use". Every address is now listed on those platforms too, with the ones after an adapter's first marked as aliases exactly as on Linux. A Linux host without `iproute2` installed gains the same thing, having previously been limited to one address per interface for the same reason. The wiki gained the Windows instructions for adding those addresses, which it had for every other platform but that one, and the creation steps it describes now match the dialog — bind interface, model and access code are set on the printer's card after **Create**, not in the dialog itself.
|
||||
- **A macOS native install could silently lose all access to the printer after a system or Homebrew update (#3114, reported by @TheeBobbyDonuts)** — macOS grants Local Network permission to a code signature, and judges a launchd-spawned process on its own rather than letting it inherit the grant of the Terminal that started it. Homebrew ships Python unsigned on Intel, so there was no identity for that grant to attach to: every connection to the printer was dropped with no error Bambuddy could log and no permission prompt to answer. The web interface worked, the printer showed as disconnected, all three port checks reported unreachable while the subnet check passed, and the log recorded no MQTT attempt at all — not a failure, just silence. Toggling the Python entry in Privacy & Security did nothing, because the entry referred to an identity that no longer resolved. `install.sh` now ad-hoc signs the interpreter during a macOS install, and `install/update_macos.sh` re-checks it on every update, since `brew upgrade python` lays down a fresh unsigned binary under a new versioned path. Both sign only what is currently unsigned: on Apple Silicon the linker already ad-hoc signs every binary, and re-signing one would rotate the hash its identity is built from and revoke a working grant on each update. The connection diagnostic also gained a **macOS Local Network permission** check, which reports the missing signature where there is one, and otherwise points at System Settings — where an Apple Silicon machine, whose identity changes with every Python upgrade, can re-grant it.
|
||||
- **The Library bulk add-to-queue answered 200 for a call that queued nothing, and queued items nothing could dispatch (#3112, reported by @toxxicpickles)** — `POST /api/v1/library/files/add-to-queue` reported every per-file rejection in an `errors` array and returned `200` regardless, so a caller that checked the status code saw a successful request, no visible failure, and no queue item. That is now a `400` when nothing at all was added, with the same per-file reasons in the body; a call that created some items still succeeds, because it did. The items it created were also aimed at nothing: the route always wrote them with no printer and no target model, and the scheduler dispatches on one or the other, so those rows could never be picked up by anything and sat in Unassigned until someone opened each one and assigned it by hand. The request now takes an optional `printer_id` or `target_model` for the batch, and with neither it aims each file at the model its own G-code declares — when a printer of that model is active, since aiming a job at hardware nobody owns would only look like progress. Three gates `POST /api/v1/queue/` has applied for a while now apply here too, because an item reaching the scheduler through this route has to be as printable as one reaching it through that one: the cross-model check that stops a file sliced for one printer being dispatched to another, the filename check that would otherwise surface as a failed upload hours later, and the filament requirements the scheduler matches before handing a model-based item to hardware. This endpoint has no caller inside Bambuddy — the Library's own Print action goes through the queue API with a printer already chosen — which is how it came to drift this far from it.
|
||||
- **The connection diagnostic told anyone whose LAN was not a /24 that their printer was on a different network (#3092, reported by @cwawak)** — the reporter's LAN is `192.168.96.0/22`, his Bambuddy sits at `192.168.96.9` and his printer at `192.168.98.170`, four hundred addresses inside the same subnet. The *Network subnet* check warned that the two were on different networks and told him to go configure routing between two halves of one LAN. An IPv4 address does not carry its prefix, and the check supplied `/24` for both sides — the most common LAN, and not the only one. It now reads the prefix off the interface that owns the address, so a `/22`, a `/16` and a `/25` are each read as written, and when no local interface claims the address the check is skipped rather than asserting a split it cannot see. The same check also picked which of Bambuddy's own addresses to compare by probing a route toward `10.255.255.255`, which on any host with more than one network card is not the card the printer is on; it now asks for the route toward the printer itself. On a two-NIC host that alone was enough to warn about a printer sitting on the second card's own subnet.
|
||||
- **Podman and LXC installs were told they were not running in a container at all (#3092, reported by @cwawak)** — the *Docker network mode* check said "Not running in Docker — not applicable" to a Bambuddy inside a Podman container, which reads as "you are on bare metal" and sends people looking for the problem somewhere else. Podman runs Bambuddy in exactly the same two shapes Docker does, and the shape is what breaks printer discovery and the Virtual Printer. The check is now **Container network mode**: it names the engine — Docker, Podman, Kubernetes, containerd — and reports host or bridge networking for each of them. Detection of the mode itself gained the general form of the signal it always used: a container that can see an interface created in its own network namespace, rather than only one end of a veth pair, is sharing the host's. A container whose mode genuinely cannot be read now says so and suggests host networking if discovery is failing, instead of guessing "bridge" and telling a perfectly healthy install to recreate itself. An LXC or LXD system container is named too, and told that the question does not apply to it — it sits on the LAN like a small virtual machine, so there is no network mode to recommend. The support bundle now carries the engine name beside the Docker flag for the same reason. Discovery's own Docker detection is deliberately untouched: it switches the Add Printer flow from SSDP to subnet scanning, and SSDP works for a host-networked Podman container.
|
||||
- **SpoolBuddy said "Unknown color" for spools Bambuddy names perfectly well (#3090, reported by @Sawtaytoes)** — the reporter scanned a Bambu Lab PLA Silk+ spool that his inventory card calls Candy Red and the kiosk showed the right red swatch above the words "Unknown color". The name was never in the spool record: Bambu's RFID tags often carry none, so Bambuddy has always resolved the swatch's own hex against the colour catalog instead — and the kiosk was printing the empty column. Every screen in SpoolBuddy that shows a colour now resolves it the same way the rest of Bambuddy does, which also stops the ones that had a name on file from showing Bambu's internal code ("A06-D0") in place of it. Searching the inventory by a colour you can read on screen finds it now, in the kiosk and in Bambuddy, instead of matching only what was stored. On Spoolman-backed inventory the catalog also wins over the spool's subtype, which Spoolman installs were being shown as a colour name because Spoolman has no field for one — so those spools said "Silk+" where they now say Candy Red. The label the kiosk falls back to when nothing can name a colour is translated in all 14 languages rather than being English for everyone.
|
||||
- **An external RTSP camera could pass the connection test and still show a black live view (#3082, reported by @M1XZG)** — the reporter's Wyze cam, republished through go2rtc, captured a frame for the test button and played in VLC, but the live view produced no image and ended after a few seconds. The two RTSP paths were not asking ffmpeg for the same thing: the one-shot capture used ffmpeg's own probe defaults, while the live stream hard-coded a 32-byte probe with no analysis. That is enough for a camera that describes itself in its SDP, and not enough for one that sends its H.264 parameters in-band a moment later — ffmpeg then never starts a decoder and yields nothing at all. The live stream now probes on the defaults like the capture beside it, so the test button means what it appears to mean. The low-latency settings are unchanged, and so is the camera handling for Bambu's own printers, which is tuned per model against a camera we know.
|
||||
- **The jog API pushed an A1's nozzle at the plate when asked for clearance (#1334, reported by @AQU4R1U5)** — `POST /printers/{id}/bed-jog` takes a signed nozzle-bed gap: positive asks for more room between the nozzle and the plate. The reporter sent `distance=5` to his A1 and the toolhead came down instead. The endpoint had been flipping the sign on A1 models since the original report on this issue, where an A1 Mini owner clicked an arrow labelled "move the plate up" and watched the nozzle dive — but that flip was solving a labelling problem in the transport layer, and it turned a parameter documented as model-independent into one that meant the opposite thing on a quarter of the fleet. `Z` is the nozzle-to-bed distance on every Bambu model, whether the bed drops away from a fixed nozzle or the nozzle rises off a fixed bed, so the endpoint now sends `distance` through unchanged and one call means one physical outcome everywhere: positive is the safe direction on every printer. The arrows on the printer card are unchanged and still show you what you would expect to move — the card works out which gap change its own arrows stand for, which is where a question about the machine in front of you belongs. On an A1, A1 Mini or A2L those buttons now say *toolhead* rather than *plate*, since a bed-slinger's plate does not move in Z at all. **A2L owners had none of this**: the original fix listed the A1 models by name and the A2L, which slings its bed the same way, was never on the list — its up arrow has been sending the toolhead down since the machine was supported.
|
||||
- **A queue item pinned to one printer never said why it was waiting (#3074, reported by @Sawtaytoes)** — the reporter watched a job pinned to a specific X1C sit at Pending for fourteen minutes, with nothing in the UI or the API to say why, while that printer ran a print he had started from its own screen. The same job queued as "Any X1C" explains itself, because the model-based half of the scheduler builds a sentence for every way a job could not start and puts it on the row. The pinned half never wrote that field at all: the Home Assistant sensor interlock was its only writer, and it actively cleared the field whenever no sensor was holding the printer. Pinned items now say what they are waiting on in the same words — **Busy: X1C-01** while it prints, while it dries, or while another job on that printer goes first; **Offline, no Auto On smart plug: X1C-01** when nothing can switch it back on — and the row shows the purple **Waiting** badge instead of a silent Pending. "Finished, waiting for you to confirm the plate is clear" is split out from plain busy and named as itself, because it is the one case on that list that does not resolve on its own. A printer that is simply printing still sends no notification, exactly as before, and neither does a sensor interlock; only the cases that need you to go and do something do. On the queue timeline, a job that is merely next in line still draws its forecast bar as before, while one that is waiting for you now drops off it rather than promising a start time nobody can keep. Nothing about which job goes out, or when, has changed.
|
||||
- **A plate printed entirely from the external spool stalled at preheat and failed (#3087, reported by @Notaseraf)** — the reporter's P1S heated up, sat there for ten minutes and then paused with HMS 07FF_8012, "Failed to get AMS mapping table". Resuming just reheated it; prints that used the AMS were fine. The plate was a single filament out of a seven-filament MakerWorld project, mapped by hand to the external spool. A project's filaments are numbered across the whole project, so a plate that prints only the seventh one carries six placeholder entries in front of it — the same shape BambuStudio sends. Bambuddy read that as "nothing here is on the spool holder" and told the printer to use the AMS anyway, with a mapping that pointed at no tray at all, which is precisely the mapping table the firmware then could not find. A plate whose every printed filament sits on the external spool now dispatches with the AMS switched off, so it prints. The decision is made against the plate's own filament list rather than guessed from the mapping alone, because a placeholder entry and a filament that could not be matched to any tray look identical in the mapping, and sending the second one to the spool holder would silently print it in the wrong material (#2589). A plate that mixes the spool holder with an AMS tray, or that has a filament which matched nothing, is untouched and still behaves exactly as before, as are dual-nozzle printers — on those the same switch selects which nozzle to feed rather than whether to use the AMS.
|
||||
- **A printer that had been offline for hours could stop the whole server (#3068, reported by @bazza2000)** — the reporter's A1 had been unreachable for 38 hours but still answered on its MQTT port, which is exactly the case the connection watchdog exists for: rebuild the session with a fresh client so nothing left over from the dead one can replay onto the new print. The rebuild ended in a call that waits for the old connection's network thread to finish, and that thread was stuck part-way through a TLS handshake the printer never completed, where nothing can interrupt it. The wait had no limit and it ran on the thread that serves every request, so Bambuddy stopped answering — the web UI, the API and the health check alike — while the process itself stayed up, which is why Docker's restart policy never kicked in. Retiring an old connection no longer waits for it: the replacement is built immediately and the old one is shut down in the background, cut off from the new session first so a printer that eventually answers cannot report itself connected again after being replaced. The same wait sat on three other printer paths — the queue's dispatch recovery, the staleness check behind an ordinary status poll, and editing, deleting or disconnecting a printer — and on the shutdown of the MQTT relay and smart-plug connections, where a wedged broker kept the process from exiting at all. All of them are fixed together. A connection that takes more than a few seconds to shut down now says so in the log instead of silently taking the server with it.
|
||||
- **Auto-drying skipped every composite spool (#3067, reported by @TheUltimateC0der)** — the reporter's AMS-HT would not dry PA6-CF, and drying the same spool by hand worked. The scheduler reduced a tray to a preset key by splitting on spaces only, so "PA6-CF" stayed "PA6-CF", matched none of the eight rows the preset table has, and the tray was read as holding nothing worth drying. It was never only nylon: of the 41 types a printer can report, 33 had no row under that rule, and 20 of those — every -CF, -GF and -AERO variant of PLA, PETG, ABS, ASA, PC and PA — have a base material sitting right there. The drying popover has resolved these correctly since 1.2.5, which is exactly why doing it by hand worked; the scheduler now shares that rule, including the alias map that reads PA6, PA11, PA12, PAHT, PPA and Nylon as PA. A row you have added for the exact type still wins over the base material's, and a material whose base is not in the table either is still left alone rather than dried at a temperature nobody chose. The same lookup decides per-filament humidity thresholds and the chamber preheat target, so a composite now gets the override and the preheat that were set for its material too.
|
||||
- **An archive gave up on its 3MF for good after one slow transfer (#3063, reported by @dfrysinger)** — the reporter's P1S had the file on its card and was serving it; the 19 MB transfer just did not finish inside the budget while the printer was also running its camera, its status messages and the job upload. Bambuddy wrote an empty archive and never looked again — and then downloaded that same file successfully three times in the next two minutes, throwing each copy away, because the only code that would have attached one had already run. Recovery for a fallback archive existed but was armed for exactly one give-up, the FTPS cool-off. It now also covers a transfer that ran out of time: Bambuddy comes back after one, four and ten minutes and fills the row in where an attempt lands. The two are told apart by what the printer said — a file that genuinely is not on the card is answered with 550, and that answer does not improve with waiting, so nothing is scheduled for it. Nor for a 3MF that downloaded fine but turned out to be another plate's, where retrying would put back exactly what was just discarded. The archives banner has wording for this case too, because the old text sent an owner whose card was working to switch on a setting that was already on; it names the Connection Timeout setting instead.
|
||||
- **Items Printed could not be set to 0 after a total plate failure (#3051, reported by @tdavis75)** — a jam ruined everything on the plate while the printer reported the job a success, so the honest count of usable parts was zero; the field refused to go below one. The manual quantity override is what the documentation points at for exactly this correction, and a project's completed-items count sums that column, so there was no way to tell a project that a job produced nothing. The floor was in the edit dialog alone — the API had always stored whatever it was given, which also meant a negative count was accepted and would have subtracted from the project totals. Zero is now typeable and the column is bounded at zero, and the Filament Trends widget counts a zeroed archive as no prints rather than silently reading the 0 as "unset" and charging one.
|
||||
- **Bulk edit could not turn G-code injection on or off (#3058)** — every other per-item print option in the queue can be changed for a whole selection at once, but **Inject G-code** was only ever settable one item at a time, in the item's own edit dialog. The bulk dialog simply had no control for it, which on a queue of a few dozen jobs meant opening every single one to arm the start and end snippets an auto-print system needs — the thing bulk edit exists to avoid. It is now a tri-state next to **Auto power off after print**, unchanged by default like the rest, and it appears only once a G-code snippet has actually been saved for some printer model, matching the checkbox in the print dialog. Nothing changed on the server: the bulk endpoint has always accepted the field, so this was a missing control rather than a missing capability.
|
||||
- **AMS Filament Backup switched itself off with every print started from the queue (#3040, reported by @frnzzle)** — it never actually did: the printer had auto-refill on the whole time, and Bambuddy was reading its own request back as telemetry. Every `project_file` Bambuddy sent carried `"cfg": "0"`, a field Bambu Studio has never sent and the firmware ignores, but the printer echoes a command's fields back in its acknowledgement — and `cfg` is the device-config bitmask whose bit 18 is auto-refill. The acknowledgement was ingested as though it were a status frame, so 25 ms after every dispatch the badge flipped to off. On a P2S, H2C or X2D it flickered back a second later, because those repeat `cfg` in their periodic status; the P1S, A1, A1 Mini and A2L send it only in a full status dump, which arrives on connect and on Force Refresh and otherwise not at all, so there the wrong value stood until someone toggled it by hand. That mattered beyond the badge: the "prefer lowest remaining" sort is deliberately skipped while the printer reports backup off, since without a second spool to fall back on, feeding a print from the emptiest one risks running dry mid-job — which is why near-empty spools were being left untouched, the symptom that opened the report. On the A1 family it was worse still: they report no `cfg` at all, and the state is meant to stay "unknown" so that behaviour is left exactly as it was before the feature existed; the echo turned that into a definite "off" on the first queue print. Command acknowledgements are no longer read as status — for the backup bit or for the per-job timelapse flag they also echo — and the `cfg` field is gone from the print command, which is not a place to be writing printer settings from.
|
||||
- **K values missing on a second AMS, and its slots unconfigurable (#3044, reported by @Zib-Astian)** — an X2D with two AMS 2 Pro, one per hotend, showed K on every slot of the first and nothing on any slot of the second. Configure Slot was worse than blank there: the picker offered no matching profile, the slot read as though it had none bound, and choosing one changed nothing visible. Both came from the same rule. A calibration index can mean two different profiles on a dual-nozzle printer — on the maintainer's H2C, index 16 is the left hotend's black PLA at K=0.018 and 15 is the right's at K=0.020 — so Bambuddy resolves it against the slot's own hotend and shows nothing rather than the wrong number. But a printer does not always file one profile per hotend. This one files one per filament, so the second AMS's slots point at the same entries as the first, all tagged with a single extruder, and requiring a match found nothing at all. The hotend now has to actually appear in the table the printer sent before it is used to narrow anything; where it does not, the index stands on its own, which is what BambuStudio does for the same card. Printers that do file per hotend are unaffected — the wrong-nozzle number still cannot be shown, and the other hotend's profiles stay under **Other K profiles**. Also fixed alongside it: on a dual-nozzle printer the K-profile dropdown named the hotend twice on every matching option and three times on every other one.
|
||||
- **The Timeline ignored Shortest Job First (#3043)** — turning SJF on reordered the pending list and the scheduler itself, but the Timeline tab kept drawing the queue in its pre-SJF order, indefinitely. It chained each swimlane's bars by queue position alone and was never told the setting existed, so the one view whose entire job is to say *when* each print will run was the one view answering for an order the scheduler had no intention of using. Bars now chain in the scheduler's own order — jumped items first, then shortest print time, then position — and the starvation guard is visible there too, so a long print that has finally come up shows as such instead of staying buried. All three surfaces that claim to show queue order (the pending list, the "if started now" ETA, and the Timeline) now share one comparator rather than three hand-copied ones. Along the way the pending list stopped folding a model name down to its first letter when grouping lanes, which had been interleaving *Any X1C* with *Any X2D* (and *Any P1S* with *Any P1P*) into a single run of rows.
|
||||
- **Preheat & Heat Soak delayed PLA prints by minutes with nothing to preheat for (#3041)** — with the feature on, a queued PLA print sat for five to seven minutes before the file was even uploaded, and turning the feature off started it immediately. The per-filament map does what it says and gives PLA a chamber target of 0, and the chamber phase duly skipped — but the stage then heated the bed to the print's own temperature, waited for it, and held the full soak anyway, because the soak had no idea it was holding for a chamber that was never asked for. That is pure delay: the print's G-code sets the bed the moment it starts, so preheating it here only moved the warm-up ahead of the upload instead of overlapping with it, and there was no chamber to condition. A chamber target of 0 derived from the filament map now skips the whole stage, so PLA, PETG, TPU and PVA prints dispatch straight away with the feature left on. The one thing the skip still does is put the airduct flap back to cooling, which is a single command and no wait — an H2D left in heating mode by the ABS job before it would otherwise cook the PLA that follows. Explicit instructions are untouched: a chamber target of **0** typed into a print's own override still heats the bed and runs the soak, which is what the queue documentation has always promised it does, as does forcing a print's Preheat override to **On**. Prints that genuinely want chamber heat are unaffected, including on the P1S/P1P/A1 tier where the bed and the soak timer are the whole mechanism.
|
||||
- **Server-side slicing rejected MakerWorld 3MFs over filament-index fields, and the plate preview was never sanitised at all (#3030, reported by @kielsucks)** — Bambu Studio writes `0` into `wall_filament`, `sparse_infill_filament` and `solid_infill_filament` to mean "use whichever filament the object is set to". Bambu Studio and OrcaSlicer 2.4 both accept that; OrcaSlicer 2.3 and earlier counted these from 1 and reject it outright with `wall_filament: 0 not in range [1.000000,...]`, so a sidecar pinned to one of those images refused the file before it ever looked at the profiles Bambuddy supplied. This is the same shape as the `-1` inherit markers fixed in #1201, with a different marker value, so the sanitiser's allowlist is now a key-to-marker map rather than one global constant — a `-1` on a filament index is a real value and is left alone, as is a `0` on a raft field. The key is removed rather than rewritten, which is what makes it safe on every build: the slicer then falls back to its own default, which is the same `0` on the versions where `0` was legal and `1` on the older ones, which is what "the active filament" means there. Fixed alongside, and the more consequential half: **the plate preview never ran the sanitiser**, so a file that sliced fine could still fail the automatic preview and fall back to a guessed filament list — and it matters more there than in a real slice, because the preview deliberately runs on the file's own embedded settings and so has no `--load-settings` pass that could supply a replacement for a rejected field. Both paths now share one sanitiser.
|
||||
- **The Slice action offered Bambu Studio files its URI handler cannot load (#3029)** — "Open in Slicer" was offered for `.stl`, `.step` and `.stp` as well as `.3mf`, on the reasonable assumption that a slicer which opens an STL from its own File menu will open one from a link. Bambu Studio does not. Every `bambustudio://` and `bambustudioopen://` URL goes through one import path that refuses any filename which is not `.3mf`, and it refuses it *before* fetching anything — the user gets "Download failed, unknown file format", which names the format and so reads as a broken model rather than an unsupported handoff. OrcaSlicer has no such limit: only MakerWorld links go down its equivalent path, and a link to your own Bambuddy goes to its general downloader, which does not look at the extension at all. So the action is now offered per slicer. With **Open in Slicer** set to Bambu Studio the File Manager offers Slice on 3MF only; set it to OrcaSlicer and STL, STEP and STP come back. In the 3D preview, where both slicers are reachable from one split button, a file the configured slicer cannot take is offered to the other one instead, with the button naming it rather than quietly sending the file somewhere the setting did not ask for. None of this touches the in-app slicer: with **Use Slicer API** on, Slice goes to the sidecar and still takes STL and 3MF whichever desktop slicer is configured.
|
||||
- **"Slice" handed the slicer a download link that only worked once (#3029)** — the Slice action in the File Manager, and Open in Slicer on the Archives page, mint a short-lived token and put it in the URL, because a protocol handler cannot carry an `Authorization` header. That token was spent by the first request to reach the endpoint, which made the handoff depend on the slicer fetching the URL exactly once — and nothing guarantees that. Bambu Studio's own downloader retries three times after a failed attempt, transfers get resumed, on-access scanners fetch. Whichever request arrived first won and every later one got a 403, so the slicer opened on an error about the file instead of on the file, and the message it showed described the 403 body it had been handed rather than anything wrong with the model — which made this very hard to read from the user's side. The three protocol-handler downloads — a library file, an archive's sliced 3MF, an archive's source 3MF — now accept their token for the rest of its five-minute life. Nothing else about it changed: it is still bound to the single file it was minted for, still expires in five minutes, and is still refused everywhere else. The two downloads that are not handoffs stay single-use, because what they hand over is itself consumed — a prepared printer-file bundle is deleted the moment it has been streamed. Fixed on the way through: **Open source 3MF in slicer never worked at all with authentication enabled**. The gateway that lets these token-in-URL downloads past the login check matches the path fragment `/dl/`, and the source 3MF's route says `source-dl`, which does not contain it — so the slicer's header-less request was turned away as unauthenticated before the route's own token check ever ran. It failed on every install with auth on, and only there.
|
||||
- **`camera:view` was required to see any image in the app (#3025, reported by @lonix)** — thirteen routes that have nothing to do with a camera took the camera stream token as their credential: library and archive thumbnails, plate previews and plate thumbnails, timelapses, print photos, archive QR codes, project cover images, print-log thumbnails, printer cover images and external-link icons. A browser cannot put an `Authorization` header on an `<img src>`, so those routes need a credential that fits in the URL, and the camera token was the only one there was. But the only way to mint one is `camera:view`, so a user granted library access to their own files got a grid of broken images until they were also granted the live camera — which on a home install points at the room the printer is in, and is not something you hand to a commission client so their own thumbnails will load. Those routes now take a new media token, minted by `POST /auth/media-token` behind plain authentication and carrying the identity of whoever asked, so each of them applies the permission its own resource is governed by: `library:read_own` for a library thumbnail, `projects:read` for a project cover, `external_links:read` for a sidebar icon. The camera stream token keeps the three routes it was named for. The frontend fetches a media token for every signed-in user and asks for a camera token only when the user can actually mint one, which also stops the 403 that used to fire on every page load for everyone else. Fixed on the way through: the printer file-browser's plate thumbnails, which had no query-token support at all and so 401'd in the file manager whenever auth was enabled; and the project cover image in the edit dialog, whose cache-busting `?v=` was appended after the token and corrupted it.
|
||||
|
||||
**Upgrade note:** a long-lived `camera_stream`, `camwall` or `overlay` token is no longer accepted on those thirteen routes — the split is the point of the change, and those tokens are handed to kiosks, walls and Home Assistant to display video. The cam wall, the streaming overlay and the kiosk views use only the three camera routes and are unaffected. If you have an integration pulling a thumbnail or a cover image with a pasted camera token, it should authenticate with an API key instead: the media routes accept `X-API-Key` and `Authorization: Bearer` directly, which the camera-token-only versions did not.
|
||||
- **The Finance sidebar entry was hidden from every non-admin, whatever their permissions (#3023, reported by @lonix)** — `cost_centers:read_own` exists so a user can see their own wallet, balance and cost-centre spend, and the page itself always worked if you typed the URL. The sidebar hid it anyway. It decides whether to offer Finance by reading `billing_enabled` from `GET /settings`, which requires `settings:read`; a non-admin gets 403 there, so the value arrived undefined and the entry was suppressed for exactly the people the permission was written for. The shell read three more fields from that same 403, and one of them failed the other way: the Notifications gate tests for `false`, which an undefined value never is, so an administrator who switched user notifications **off** still left the entry showing to the non-admins it governs — a fault no administrator could reproduce, because administrators can read `/settings`. The sponsor prompt also showed EUR regardless of the configured currency, and the update check ran on installs where it had been turned off. `settings:read` cannot be the price of any of this: it also grants sight of the SMTP, LDAP and MQTT credentials. These four now come from a new `GET /settings/ui-flags`, which asks only that the caller be signed in. It is deliberately not more fields on the existing public `/settings/ui-preferences`: that endpoint is served to anyone at all because its contents are defaults shipped with the app, while these describe how your particular install is configured, so they sit behind authentication instead.
|
||||
- **A queued print showed "ASAP" in the queue even when Queue was the option chosen (#3018, reported by @kilrah; also #2557, reported by @ddavidebor)** — the Print dialog offers ASAP, Queue and Schedule, but ASAP and Queue differ only in where the item lands in the list, and neither is recorded on the item itself. The queue's time column had nothing to read but the scheduled time, so it labelled every unscheduled item "ASAP" — the name of the one mode the user may well not have picked. Someone who chose Queue, watched the row appear as ASAP and then watched it start, reasonably concluded Bambuddy had overridden them. That column answers when an item runs, so it now says that: **When a printer is free**, in all fourteen languages. The dispatch itself was correct and is unchanged — a print scheduled for later does not reserve the printer until then, so an unscheduled item behind it uses the printer in the meantime rather than leaving it idle until the scheduled time. The queue's own per-printer log line was no help in telling those apart either: it called a printer "not available" whether the queue could not use it or had just picked it for this pass, and printed printer state read at the moment of logging rather than the state the decision was made on — so a support bundle could show "printer 1 not available — state=IDLE" immediately above that printer receiving a job. Each printer now reports which of the two it was, and why.
|
||||
- **An archive left empty by a printer refusing FTPS blamed the slicer (#2780, reported by @AntonPalmqvist)** — when a printer's file service answers port 990 with something that is not TLS, Bambuddy pauses transfers to it and the print in flight is archived with its name and timing only. That cause has been recorded on the archive since #2957, but the Archives banner was never taught about it: it ranked the three causes it knew, found none of them, and fell back to the original wording — the slicer did not leave the file on the card, go and switch on "Store sent files on external storage", here is a link to installation step 4. Every part of that is wrong for a refused handshake. The slicer did write the file, the reporter could see it on the stick from his own computer, the setting was already on, and there is nothing on his side to change. Worse, the banner dismisses one-shot into browser storage, so being shown the wrong explanation once meant never being shown the right one. It now has its own wording, in all fourteen languages, saying that the printer refused the connection, that this is not a slicer setting and not something the operator did, that Bambuddy comes back for the file after the five-minute pause so a brief episode fills itself in, and that a card still empty means the refusal outlasted the retry. It links to the troubleshooting entry for the handshake failure rather than to the installation guide. This cause now outranks the other three rather than falling to the bottom: the other three describe an install working as configured and each ends in something the operator can change, while this one reports a fault we cannot yet explain — and given the one-shot dismissal, ranking it below anything else would have hidden it permanently. It cannot mask a permanent cause in return, because the retry clears the marker when it succeeds, so a card still carrying this one is a printer whose file service is still refusing. The wiki page these reports are pointed at has also been corrected: it told people to power-cycle the printer, which the reporter who prompted that advice did on both of his printers to no effect, and which the code stopped saying for that reason. It now states what has been measured, says plainly that the trigger is unknown, and names the one log line worth collecting.
|
||||
- **Custom filament profiles arrived in the slicer as Generic, or as the Bambu profile they were built on (#3003, reported by @marivo)** — the slot's filament id is the one field a custom profile travels in, and it holds eight characters on the printer. Bambuddy was putting an eighteen-character preset identifier in it whenever it could not find a real filament id. The printer stored the first eight and reported success, so the slot pointed at something that resolves nowhere: the slicer showed Generic and the printer's calibration table, keyed by the same field, no longer had a slot to key. Three printers in the support archive show it happening — an A1, a P1S and an H2D — so it was never specific to one model. Bambuddy now sends the slot's existing filament id, or the generic one for the material, both of which fit. Orca Cloud profiles were additionally never looked up at all, so theirs went in as a thirty-six-character identifier and fared worse still; they are now resolved like every other source. Also fixed on the way through: a failed Orca Cloud sign-in left its HTTP connection behind instead of closing it, which went unnoticed while only the settings page could trigger it and would have repeated on every spool assignment once the lookup above started using the same code. A profile that carries no filament id of its own — which is every profile created in OrcaSlicer, whose preset format has no such field — still cannot be told apart from the one it inherits from.
|
||||
- **Hovering a muted control in the light theme made its label vanish (#1909, reported by @AntonPalmqvist)** — the tab strip on the Print Queue page was where it got noticed: point at **Batches** and the word turned white on a near-white background. It was never about that tab. The light theme keeps `text-white` readable by remapping it to the theme's foreground colour, but that remapping only ever matched the plain utility, not the `hover:` variant Tailwind compiles to a different selector — so roughly 400 controls across the app that dim their label at rest and brighten it on hover were brightening it to literal white, whatever the theme. They now follow the theme like everything else. Dark themes are unaffected, their foreground colour already being white, and the handful of buttons that turn a solid accent colour on hover read better for it rather than worse.
|
||||
|
||||
### Security
|
||||
- **Bumped the Tiptap editor stack to 3.31.1 for a prototype-manipulation advisory in `@tiptap/core` (GHSA-cp6q-959q-f8rh)** — `mergeAttributes()` copies keys straight out of `Object.entries()` with ordinary bracket assignment, so an own `__proto__` key coming from JSON invokes the legacy prototype setter instead of writing a property: the returned object carries an attacker-controlled prototype while `Object.keys()` and own-property checks show nothing. That matters because ProseMirror's `DOMSerializer.renderSpec()` enumerates attribute objects with `for...in`, which walks inherited keys — an inherited `src` and `onerror` become real attributes on a rendered `<img>` and the handler runs in the app's origin. Medium severity, CVSS 4.0 6.4; the fix landed in 3.30.4 and the whole stack moves 3.19.0 → 3.31.1. **No running Bambuddy install was exposed.** The advisory needs either an untrusted object reaching `mergeAttributes()` or a custom/dynamic extension that preserves the attribute object, and Bambuddy has neither: nothing under `frontend/src/` calls `mergeAttributes` or defines an extension, and the one editor (`RichTextEditor`) builds a fixed schema from StarterKit plus six stock extensions whose `HTMLAttributes` are static literals. Content also crosses the boundary as an HTML **string**, never as JSON, so no own `__proto__` key can reach an attributes object in the first place — ProseMirror's DOM parser only fills in the attributes the schema declares — and every read-only render of that content is sanitized (`DOMPurify.sanitize` for project notes, `sanitizeHtml` in the project-page modal). This is a lockfile-only change: `frontend/package.json` already declared `^3.11.1`, so the patched line was inside the existing range and only the stale lock held it back; no `overrides` entry was needed. A side effect worth recording is that `@tiptap/pm` has narrowed what it pulls in, so `prosemirror-markdown`, `prosemirror-menu`, `prosemirror-collab`, `prosemirror-schema-basic`, `prosemirror-trailing-node`, `markdown-it` and `linkify-it` leave the tree entirely (16 packages) — which retires the reachability argument recorded for the `linkify-it` bump in 1.2.5, since that package is simply no longer there. Verified with eslint, the production build and its Safari 16 baseline check, and the full frontend suite (3514 tests across 256 files); `npm audit --omit=dev` reports zero vulnerabilities.
|
||||
- **Bumped the build and lint toolchain for three development-dependency advisories in `browserslist` and `@humanfs/node` (GHSA-73wf-gq98-2v4g, GHSA-c83g-rgw3-j3cx, GHSA-p498-v437-472g)** — `browserslist` moves 4.28.1 → 4.28.8 for two high-severity issues: `normalizeStats()` walks an untrusted `browserslist-stats.json` with an unguarded `for...in` and uses the keys for plain bracket access and assignment, so a `__proto__` or `constructor` key either crashes the build or writes to the prototype (CVE-2026-73088), and the query-result cache has no eviction at all, so a long-lived process fed distinct queries grows without bound (CVE-2026-73089). `@humanfs/node` moves 0.16.7 → 0.16.8 for a medium-severity path-traversal issue where `copyAll()` ignores symlink state and `fs.copyFile()` dereferences the link, copying data from outside the source tree. **No running Bambuddy install was exposed, and neither issue was reachable even at build time.** Both packages are development-only — they are absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. `browserslist` is never called by our own code; it arrives under `autoprefixer` and `@babel/helper-compilation-targets`, there is no `browserslist-stats.json` anywhere in the repository or up the directory tree, no `browserslist` key in `package.json` and no `.browserslistrc`, and nothing passes `--stats` or `opts.stats`, so the untrusted input the first advisory needs has no way in; the unbounded cache needs a long-lived process taking attacker-chosen queries, where `vite build` is one-shot with a fixed query. `@humanfs/node` arrives under `eslint`, which calls only `isDirectory` and `walk` and never copies anything, so the copy path the advisory describes is never entered. Lockfile-only — every existing range already admitted the patched versions, so `frontend/package.json` is untouched. The bump carries `caniuse-lite` 1.0.30001769 → 1.0.30001810, `baseline-browser-mapping` 2.9.19 → 2.11.20, `electron-to-chromium` 1.5.286 → 1.5.420, `node-releases` 2.0.27 → 2.0.54 and `update-browserslist-db` 1.2.3 → 1.3.2, all of which feed autoprefixer's target data — the rebuilt bundle is byte-identical, same content hashes, so `static/` does not change. Verified with a clean build against the Safari 16.0 baseline check, eslint, 3514 frontend tests across 256 files, i18n parity in all 13 locales, and `npm audit` reporting zero findings with and without dev dependencies.
|
||||
- **Bumped `fflate` to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034)** — `unzipSync()` treats a central-directory entry whose compressed size is the Zip64 sentinel `0xFFFFFFFF` as a Zip64 entry and scans its extra field for the header id `0x0001`, with nothing telling the scan where that field ends: `z64e()` then reads past the end of the buffer, the `undefined` that comes back coerces to 0, and the loop condition can never go false, so the tab spins at 100% CPU until it is killed (CVE-2026-45820, medium, CWE-400). **No running Bambuddy install was exposed.** Unlike the other two entries here this one is classified runtime rather than development scope, so it is worth saying exactly why it cannot fire: `fflate` reaches the tree only as a dependency of `@types/three`, which is a types-only package whose imports TypeScript erases at compile time, so it never becomes a runtime import at all. The ten three.js addons that genuinely call it — `3MFLoader`, `FBXLoader`, `KMZLoader`, `AMFLoader`, `EXRLoader`, `USDLoader`, `VTKLoader`, `NRRDLoader`, `USDZExporter` and `EXRExporter` — are imported nowhere in the frontend or the backend; the four this project does use, `OrbitControls`, `BufferGeometryUtils`, `STLLoader` and `RoomEnvironment`, reference none of it. `3MFLoader` is the one worth checking twice given what Bambuddy spends its time reading, and it is genuinely absent: 3MF files are parsed on the backend, not in the model viewer. The shipped bundle confirms it, carrying no fflate signature whatsoever — `unzipSync`, `z64e`, `invalid zip data`, `no stream handler` and `extra field too long` are all absent, and the one inflate error string that does appear belongs to pako, which the surrounding `e.msg=` / `n.mode=30` zlib-port idiom identifies unambiguously. Lockfile-only: three lines, no `frontend/package.json` change, nothing added or removed, and no transitive churn. The reason a package that never ships shows up in runtime scope at all is that `@types/three` sits in `dependencies` rather than `devDependencies`, which is left alone here rather than moved in a security bump.
|
||||
- **Bumped Vitest to 4.1.11 for a path-traversal advisory in `@vitest/mocker` (GHSA-82fw-gwwq-j7x9)** — the mocker registers a redirect mock's target path without checking it against Vite's file-serving allowlist, and the plugin's `load` hook then hands back `readFile(mock.redirect)` as the module source. Registration derives that path as `join(server.config.root, new URL(event.redirect).pathname)`, which does not confine anything: a non-special scheme leaves `..` segments in `pathname`, so the join resolves outside the project root, and even a path that stays inside it is read without the `server.fs.deny` check the dev server would otherwise apply to an in-root `.env`. Medium severity, CVSS 3.1 5.9, CWE-22; fixed in 4.1.11 and 5.0.0, with 2.1.x and 3.x unmaintained. **No running Bambuddy install was exposed, and the issue is not reachable in this project's test runs either.** The unauthenticated variant is specifically the public `mockerPlugin` and standalone `interceptorPlugin` exports, which attach the handler to Vite's HMR WebSocket — a socket with no token, Origin or same-origin check — and those exports exist for third-party dev servers embedding the mocker; nothing under `frontend/src/` imports either one. Vitest's own browser mode registers mocks over a token-authenticated RPC instead, and it is not installed here at all: `@vitest/browser` appears in the lockfile only as an unmet optional peer, and `vitest.config.ts` runs a plain jsdom environment, so no dev server is listening during a test run in the first place. Both packages are development-only — absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. Fifteen lockfile entries move, every one of them dev-scoped, with nothing added or removed: the eight `@vitest/*` packages and `vitest` itself go 4.1.8 → 4.1.11, carrying `es-module-lexer` 2.1.0 → 2.3.2, `expect-type` 1.3.0 → 1.4.0, `obug` 2.1.1 → 2.2.1, `std-env` 4.1.0 → 4.2.0, `tinyexec` 1.2.4 → 1.3.1 and `tinyrainbow` 3.1.0 → 3.1.1. Unlike the other entries here this one does touch `frontend/package.json`: the existing `^4.1.8` range already admitted the patched version, so the lock alone would have pinned it, but the declared floor is raised to `^4.1.11` so that a regenerated lockfile cannot resolve back below the fix. Nothing in `src/` changed, so the rebuilt bundle is byte-identical and `static/` does not move. Verified with the full frontend suite running on 4.1.11 (3562 tests across 259 files), eslint, the production build and its Safari 16.0 baseline check, i18n parity in all 13 locales, and `npm audit` reporting zero vulnerabilities with and without dev dependencies.
|
||||
|
||||
|
||||
|
||||
## [1.2.5.5] - 2026-08-30
|
||||
|
||||
### Changed
|
||||
@@ -11,12 +73,6 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
### Fixed
|
||||
- **Every camera stopped working on 1.2.5.4 (#3001, reported by @Jieper001, confirmed by @JmanB52D and @hikingthunder)** — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting `capture_exception` at 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute. `asyncio.start_server` returns an `asyncio.base_events.Server`, which has a `__dict__` and accepts that; under uvloop it returns a `uvloop.loop.Server`, a Cython cdef class with no `__dict__`, which raises `AttributeError` outright. Every launch path this repo ships — the Dockerfile, `install/install.sh`, `deploy/bambuddy.service`, the Windows service and the SpoolBuddy installer — pins `--loop asyncio`, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its own `ExecStart` with no loop pinned, and `requirements.txt` pins `uvicorn[standard]`, which installs uvloop on Linux, so uvicorn's default `--loop auto` selects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason: `install/update.sh` never rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a `.gcode.3mf` silently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than by `id(server)` means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is that `conftest` builds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a `__slots__` server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched to `close_tls_proxy`, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them.
|
||||
|
||||
### Security
|
||||
- **Bumped the Tiptap editor stack to 3.31.1 for a prototype-manipulation advisory in `@tiptap/core` (GHSA-cp6q-959q-f8rh)** — `mergeAttributes()` copies keys straight out of `Object.entries()` with ordinary bracket assignment, so an own `__proto__` key coming from JSON invokes the legacy prototype setter instead of writing a property: the returned object carries an attacker-controlled prototype while `Object.keys()` and own-property checks show nothing. That matters because ProseMirror's `DOMSerializer.renderSpec()` enumerates attribute objects with `for...in`, which walks inherited keys — an inherited `src` and `onerror` become real attributes on a rendered `<img>` and the handler runs in the app's origin. Medium severity, CVSS 4.0 6.4; the fix landed in 3.30.4 and the whole stack moves 3.19.0 → 3.31.1. **No running Bambuddy install was exposed.** The advisory needs either an untrusted object reaching `mergeAttributes()` or a custom/dynamic extension that preserves the attribute object, and Bambuddy has neither: nothing under `frontend/src/` calls `mergeAttributes` or defines an extension, and the one editor (`RichTextEditor`) builds a fixed schema from StarterKit plus six stock extensions whose `HTMLAttributes` are static literals. Content also crosses the boundary as an HTML **string**, never as JSON, so no own `__proto__` key can reach an attributes object in the first place — ProseMirror's DOM parser only fills in the attributes the schema declares — and every read-only render of that content is sanitized (`DOMPurify.sanitize` for project notes, `sanitizeHtml` in the project-page modal). This is a lockfile-only change: `frontend/package.json` already declared `^3.11.1`, so the patched line was inside the existing range and only the stale lock held it back; no `overrides` entry was needed. A side effect worth recording is that `@tiptap/pm` has narrowed what it pulls in, so `prosemirror-markdown`, `prosemirror-menu`, `prosemirror-collab`, `prosemirror-schema-basic`, `prosemirror-trailing-node`, `markdown-it` and `linkify-it` leave the tree entirely (16 packages) — which retires the reachability argument recorded for the `linkify-it` bump in 1.2.5, since that package is simply no longer there. Verified with eslint, the production build and its Safari 16 baseline check, and the full frontend suite (3514 tests across 256 files); `npm audit --omit=dev` reports zero vulnerabilities.
|
||||
- **Bumped the build and lint toolchain for three development-dependency advisories in `browserslist` and `@humanfs/node` (GHSA-73wf-gq98-2v4g, GHSA-c83g-rgw3-j3cx, GHSA-p498-v437-472g)** — `browserslist` moves 4.28.1 → 4.28.8 for two high-severity issues: `normalizeStats()` walks an untrusted `browserslist-stats.json` with an unguarded `for...in` and uses the keys for plain bracket access and assignment, so a `__proto__` or `constructor` key either crashes the build or writes to the prototype (CVE-2026-73088), and the query-result cache has no eviction at all, so a long-lived process fed distinct queries grows without bound (CVE-2026-73089). `@humanfs/node` moves 0.16.7 → 0.16.8 for a medium-severity path-traversal issue where `copyAll()` ignores symlink state and `fs.copyFile()` dereferences the link, copying data from outside the source tree. **No running Bambuddy install was exposed, and neither issue was reachable even at build time.** Both packages are development-only — they are absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. `browserslist` is never called by our own code; it arrives under `autoprefixer` and `@babel/helper-compilation-targets`, there is no `browserslist-stats.json` anywhere in the repository or up the directory tree, no `browserslist` key in `package.json` and no `.browserslistrc`, and nothing passes `--stats` or `opts.stats`, so the untrusted input the first advisory needs has no way in; the unbounded cache needs a long-lived process taking attacker-chosen queries, where `vite build` is one-shot with a fixed query. `@humanfs/node` arrives under `eslint`, which calls only `isDirectory` and `walk` and never copies anything, so the copy path the advisory describes is never entered. Lockfile-only — every existing range already admitted the patched versions, so `frontend/package.json` is untouched. The bump carries `caniuse-lite` 1.0.30001769 → 1.0.30001810, `baseline-browser-mapping` 2.9.19 → 2.11.20, `electron-to-chromium` 1.5.286 → 1.5.420, `node-releases` 2.0.27 → 2.0.54 and `update-browserslist-db` 1.2.3 → 1.3.2, all of which feed autoprefixer's target data — the rebuilt bundle is byte-identical, same content hashes, so `static/` does not change. Verified with a clean build against the Safari 16.0 baseline check, eslint, 3514 frontend tests across 256 files, i18n parity in all 13 locales, and `npm audit` reporting zero findings with and without dev dependencies.
|
||||
- **Bumped `fflate` to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034)** — `unzipSync()` never returns on a crafted ZIP whose central-directory entry declares the ZIP64 sentinel `compressed_size=0xFFFFFFFF` but omits the required ZIP64 extra field, tag `0x0001`: `z64e()` then reads past the end of the buffer, the `undefined` that comes back coerces to 0, and the loop condition can never go false, so the tab spins at 100% CPU until it is killed (CVE-2026-45820, medium, CWE-400). **No running Bambuddy install was exposed.** Unlike the other two entries here this one is classified runtime rather than development scope, so it is worth saying exactly why it cannot fire: `fflate` reaches the tree only as a dependency of `@types/three`, which is a types-only package whose imports TypeScript erases at compile time, so it never becomes a runtime import at all. The ten three.js addons that genuinely call it — `3MFLoader`, `FBXLoader`, `KMZLoader`, `AMFLoader`, `EXRLoader`, `USDLoader`, `VTKLoader`, `NRRDLoader`, `USDZExporter` and `EXRExporter` — are imported nowhere in the frontend or the backend; the four this project does use, `OrbitControls`, `BufferGeometryUtils`, `STLLoader` and `RoomEnvironment`, reference none of it. `3MFLoader` is the one worth checking twice given what Bambuddy spends its time reading, and it is genuinely absent: 3MF files are parsed on the backend, not in the model viewer. The shipped bundle confirms it, carrying no fflate signature whatsoever — `unzipSync`, `z64e`, `invalid zip data`, `no stream handler` and `extra field too long` are all absent, and the one inflate error string that does appear belongs to pako, which the surrounding `e.msg=` / `n.mode=30` zlib-port idiom identifies unambiguously. Lockfile-only: three lines, no `frontend/package.json` change, nothing added or removed, and no transitive churn. The reason a package that never ships shows up in runtime scope at all is that `@types/three` sits in `dependencies` rather than `devDependencies`, which is left alone here rather than moved in a security bump.
|
||||
- **Bumped Vitest to 4.1.11 for a path-traversal advisory in `@vitest/mocker` (GHSA-82fw-gwwq-j7x9)** — the mocker registers a redirect mock's target path without checking it against Vite's file-serving allowlist, and the plugin's `load` hook then hands back `readFile(mock.redirect)` as the module source. Registration derives that path as `join(server.config.root, new URL(event.redirect).pathname)`, which does not confine anything: a non-special scheme leaves `..` segments in `pathname`, so the join resolves outside the project root, and even a path that stays inside it is read without the `server.fs.deny` check the dev server would otherwise apply to an in-root `.env`. Medium severity, CVSS 3.1 5.9, CWE-22; fixed in 4.1.11 and 5.0.0, with 2.1.x and 3.x unmaintained. **No running Bambuddy install was exposed, and the issue is not reachable in this project's test runs either.** The unauthenticated variant is specifically the public `mockerPlugin` and standalone `interceptorPlugin` exports, which attach the handler to Vite's HMR WebSocket — a socket with no token, Origin or same-origin check — and those exports exist for third-party dev servers embedding the mocker; nothing under `frontend/src/` imports either one. Vitest's own browser mode registers mocks over a token-authenticated RPC instead, and it is not installed here at all: `@vitest/browser` appears in the lockfile only as an unmet optional peer, and `vitest.config.ts` runs a plain jsdom environment, so no dev server is listening during a test run in the first place. Both packages are development-only — absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. Fifteen lockfile entries move, every one of them dev-scoped, with nothing added or removed: the eight `@vitest/*` packages and `vitest` itself go 4.1.8 → 4.1.11, carrying `es-module-lexer` 2.1.0 → 2.3.2, `expect-type` 1.3.0 → 1.4.0, `obug` 2.1.1 → 2.2.1, `std-env` 4.1.0 → 4.2.0, `tinyexec` 1.2.4 → 1.3.1 and `tinyrainbow` 3.1.0 → 3.1.1. Unlike the other entries here this one does touch `frontend/package.json`: the existing `^4.1.8` range already admitted the patched version, so the lock alone would have pinned it, but the declared floor is raised to `^4.1.11` so that a regenerated lockfile cannot resolve back below the fix. Nothing in `src/` changed, so the rebuilt bundle is byte-identical and `static/` does not move. Verified with the full frontend suite running on 4.1.11 (3514 tests across 256 files), eslint, the production build and its Safari 16.0 baseline check, i18n parity in all 13 locales, and `npm audit` reporting zero vulnerabilities with and without dev dependencies.
|
||||
|
||||
|
||||
## [1.2.5.4] - 2026-08-29
|
||||
|
||||
|
||||
@@ -93,10 +93,14 @@ async def get_ams_history(
|
||||
],
|
||||
min_humidity=stats.min_humidity,
|
||||
max_humidity=stats.max_humidity,
|
||||
avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity else None,
|
||||
# ``is not None``, not truthiness: an average of exactly 0 is a
|
||||
# reading, and the min/max beside it would report it while the average
|
||||
# showed an em dash. AVG over an empty or all-NULL window is the only
|
||||
# case that has no answer (#3140).
|
||||
avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity is not None else None,
|
||||
min_temperature=stats.min_temp,
|
||||
max_temperature=stats.max_temp,
|
||||
avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp else None,
|
||||
avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp is not None else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core import database
|
||||
from backend.app.core.auth import (
|
||||
RequireCameraStreamTokenIfAuthEnabled,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
check_printer_access,
|
||||
current_api_key_if_present,
|
||||
probe_permissions_if_auth_enabled,
|
||||
require_media_token_ownership,
|
||||
require_ownership_permission,
|
||||
)
|
||||
from backend.app.core.config import settings
|
||||
@@ -40,6 +40,8 @@ from backend.app.services.bambu_ftp import ftps_handshake_blocked, list_files_re
|
||||
from backend.app.services.design_settings import overrides_from_config
|
||||
from backend.app.services.filament_requirements import annotate_rack_groups
|
||||
from backend.app.services.print_storage import (
|
||||
REASON_FTP_TRANSFER_FAILED,
|
||||
REASON_FTPS_COOLOFF,
|
||||
REASON_INTERNAL_HISTORY,
|
||||
REASON_INTERNAL_STORAGE,
|
||||
REASON_NO_EXTERNAL_STORAGE,
|
||||
@@ -537,8 +539,8 @@ async def no_3mf_warning(
|
||||
single-cause wording sent people the wrong way. Historically the only
|
||||
known cause was install step 4 ("Store sent files on external storage")
|
||||
being off in the slicer, so the banner said so unconditionally. On
|
||||
H2-series and P2S that advice is actively wrong: the setting is already on
|
||||
and turning it on again changes nothing, because the printer keeps the
|
||||
H2-series, P2S and X2D that advice is actively wrong: the setting is already
|
||||
on and turning it on again changes nothing, because the printer keeps the
|
||||
sliced file on internal storage that FTPS does not serve at all (#2780).
|
||||
|
||||
``reason`` is the slug from :mod:`print_storage` when we recorded one,
|
||||
@@ -581,12 +583,41 @@ async def no_3mf_warning(
|
||||
# all, so an install with one H2C and three older printers still gets the
|
||||
# H2C explanation rather than the generic one.
|
||||
#
|
||||
# REASON_FTPS_COOLOFF leads, and it is the only one of these that reports a
|
||||
# fault rather than a choice: the printer's file service refused a TLS
|
||||
# handshake, so the sweep never ran and nothing about where the file went
|
||||
# was ever tested. The other three describe an install working as
|
||||
# configured, and each ends in something the operator can change. This one
|
||||
# ends in "your printer is doing something we cannot yet explain", which is
|
||||
# both the more urgent thing to say and the thing that produces a useful
|
||||
# report. It also has to outrank them because the banner dismisses one-shot
|
||||
# into localStorage: a reason ranked below another is not merely deferred,
|
||||
# it is never shown to that user again (#2780).
|
||||
#
|
||||
# Ranking it first cannot mask a permanent cause, because a cool-off row is
|
||||
# not permanent. The retry #2957 schedules clears the row's markers when it
|
||||
# lands, so a row still carrying this slug is one where the retry failed too
|
||||
# -- a printer whose file service is still refusing, days later.
|
||||
#
|
||||
# REASON_FTP_TRANSFER_FAILED sits second for the same reasons and one more:
|
||||
# it is the only slug here whose remedy is a Bambuddy setting rather than a
|
||||
# slicer one or a card. It ranks below the cool-off because a printer that
|
||||
# will not complete a TLS handshake is the worse fault of the two, and its
|
||||
# own retry (#3063) clears the row the same way, so a row still carrying
|
||||
# this slug is one where three later attempts also ran out of time.
|
||||
#
|
||||
# REASON_INTERNAL_HISTORY comes last on purpose, even though it is the
|
||||
# narrowest: it is the one cause with no remedy at all -- the file was
|
||||
# already on the printer, in an area port 990 does not serve. The two ahead
|
||||
# of it each end in something the operator can do, so when an install has
|
||||
# both, the actionable explanation is the one worth the banner (#1820).
|
||||
for candidate in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE, REASON_INTERNAL_HISTORY):
|
||||
for candidate in (
|
||||
REASON_FTPS_COOLOFF,
|
||||
REASON_FTP_TRANSFER_FAILED,
|
||||
REASON_INTERNAL_STORAGE,
|
||||
REASON_NO_EXTERNAL_STORAGE,
|
||||
REASON_INTERNAL_HISTORY,
|
||||
):
|
||||
if candidate in reasons:
|
||||
return {"has_fallback": True, "reason": candidate}
|
||||
return {"has_fallback": True, "reason": None}
|
||||
@@ -2291,13 +2322,15 @@ async def download_archive_for_slicer(
|
||||
):
|
||||
"""Download 3MF file using a slicer download token.
|
||||
|
||||
Token-authenticated (no auth headers needed). The token is short-lived
|
||||
and single-use, created by POST /{archive_id}/slicer-token.
|
||||
Token-authenticated (no auth headers needed). The token is short-lived and
|
||||
archive-bound, created by POST /{archive_id}/slicer-token, and redeemable
|
||||
for the rest of its TTL rather than exactly once -- the slicer is a separate
|
||||
process that may fetch the URL more than once (#3029).
|
||||
Filename is at the end of the URL so slicers can detect the file format.
|
||||
"""
|
||||
from backend.app.core.auth import verify_slicer_download_token
|
||||
|
||||
if not await verify_slicer_download_token(token, "archive", archive_id):
|
||||
if not await verify_slicer_download_token(token, "archive", archive_id, single_use=False):
|
||||
raise HTTPException(403, "Invalid or expired download token")
|
||||
|
||||
service = ArchiveService(db)
|
||||
@@ -2320,15 +2353,22 @@ async def download_archive_for_slicer(
|
||||
async def get_thumbnail(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get the thumbnail image.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
returns 404 for an archive the caller may not read (#3025).
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive or not archive.thumbnail_path:
|
||||
archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
|
||||
if not archive.thumbnail_path:
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
|
||||
thumb_path = settings.base_dir / archive.thumbnail_path
|
||||
@@ -2549,15 +2589,22 @@ async def download_archive_media_with_token(
|
||||
async def get_timelapse(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get the timelapse video.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
returns 404 for an archive the caller may not read (#3025).
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive or not archive.timelapse_path:
|
||||
archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
|
||||
if not archive.timelapse_path:
|
||||
raise HTTPException(404, "Timelapse not found")
|
||||
|
||||
timelapse_path = settings.base_dir / archive.timelapse_path
|
||||
@@ -3265,16 +3312,21 @@ async def get_photo(
|
||||
archive_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get a specific photo.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
returns 404 for an archive the caller may not read (#3025).
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
archive = result.scalar_one_or_none()
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
|
||||
|
||||
# Membership check first — UUID-generated names on upload mean any URL
|
||||
# filename that doesn't appear here is by definition not a real photo.
|
||||
@@ -3353,12 +3405,19 @@ async def get_qrcode(
|
||||
request: Request,
|
||||
size: int = 200,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Generate a QR code that links to this archive.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
returns 404 for an archive the caller may not read (#3025).
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
try:
|
||||
import qrcode
|
||||
from PIL import Image as PILImage
|
||||
@@ -3366,9 +3425,7 @@ async def get_qrcode(
|
||||
raise HTTPException(500, "QR code generation not available - qrcode package not installed")
|
||||
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
archive = result.scalar_one_or_none()
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
|
||||
|
||||
# Build URL to archive download
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
@@ -3694,19 +3751,24 @@ async def get_gcode(
|
||||
async def get_plate_preview(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get the plate preview image from the 3MF file.
|
||||
|
||||
Returns the slicer-generated plate thumbnail which shows the model
|
||||
with correct colors and positioning.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
returns 404 for an archive the caller may not read (#3025).
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.is_file():
|
||||
@@ -4227,16 +4289,21 @@ async def get_plate_thumbnail(
|
||||
archive_id: int,
|
||||
plate_index: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get the thumbnail image for a specific plate.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
returns 404 for an archive the caller may not read (#3025).
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.is_file():
|
||||
@@ -4678,18 +4745,23 @@ async def get_project_image(
|
||||
archive_id: int,
|
||||
image_path: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get an image from the 3MF project page.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
returns 404 for an archive the caller may not read (#3025).
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
from backend.app.services.archive import ProjectPageParser
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.is_file():
|
||||
@@ -4910,12 +4982,13 @@ async def download_source_3mf_for_slicer_with_token(
|
||||
):
|
||||
"""Download source 3MF using a slicer download token.
|
||||
|
||||
Token-authenticated (no auth headers needed). The token is short-lived
|
||||
and single-use, created by POST /{archive_id}/source-slicer-token.
|
||||
Token-authenticated (no auth headers needed). The token is short-lived and
|
||||
archive-bound, created by POST /{archive_id}/source-slicer-token, and
|
||||
redeemable for the rest of its TTL rather than exactly once (#3029).
|
||||
"""
|
||||
from backend.app.core.auth import verify_slicer_download_token
|
||||
|
||||
if not await verify_slicer_download_token(token, "source", archive_id):
|
||||
if not await verify_slicer_download_token(token, "source", archive_id, single_use=False):
|
||||
raise HTTPException(403, "Invalid or expired download token")
|
||||
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
|
||||
@@ -25,12 +25,14 @@ from backend.app.core.auth import (
|
||||
authenticate_user,
|
||||
authenticate_user_by_email,
|
||||
create_access_token,
|
||||
create_media_token,
|
||||
create_websocket_token,
|
||||
get_current_active_user,
|
||||
get_password_hash,
|
||||
get_user_by_email,
|
||||
get_user_by_username,
|
||||
is_jti_revoked,
|
||||
require_auth_if_enabled,
|
||||
resolve_apikey_owner,
|
||||
resolve_session_max_minutes,
|
||||
revoke_jti,
|
||||
@@ -346,10 +348,8 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
|
||||
# (#2530). Only migrate when there is exactly one obvious owner:
|
||||
# handing another admin's session a Bambu credential is not a
|
||||
# guess worth making.
|
||||
from backend.app.api.routes.cloud import (
|
||||
get_stored_token,
|
||||
migrate_global_cloud_token_to_user,
|
||||
)
|
||||
from backend.app.api.routes.cloud import migrate_global_cloud_token_to_user
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
if admin_created:
|
||||
cloud_owner = admin_user
|
||||
@@ -659,6 +659,30 @@ async def mint_websocket_token(
|
||||
return {"token": await create_websocket_token(username)}
|
||||
|
||||
|
||||
@router.post("/media-token")
|
||||
async def mint_media_token(
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
):
|
||||
"""Mint a short-lived token for ``<img>`` / ``<video>`` media routes (#3025).
|
||||
|
||||
Thumbnails, plate previews, timelapses, cover images and sidebar icons are
|
||||
loaded by the browser as element ``src`` URLs, which cannot carry an
|
||||
``Authorization`` header. Those routes used to accept the *camera stream*
|
||||
token instead, which made ``camera:view`` a prerequisite for seeing a
|
||||
library thumbnail -- on a home install, handing someone the live feed of
|
||||
the room the printer is in just so their own files render.
|
||||
|
||||
So this mints behind plain authentication: any signed-in user may ask, and
|
||||
what the token can actually reach is decided per request by the same
|
||||
permission and ownership rules as the resource's other routes. It is not a
|
||||
camera credential and does not open the camera routes.
|
||||
|
||||
Returns ``{"token": <opaque string>}``, valid for 60 minutes.
|
||||
"""
|
||||
username = current_user.username if current_user is not None else None
|
||||
return {"token": await create_media_token(username)}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
|
||||
@@ -7,7 +7,6 @@ Handles authentication and profile management with Bambu Cloud.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -23,7 +22,7 @@ from backend.app.core.auth import (
|
||||
require_permission_if_auth_enabled,
|
||||
security,
|
||||
)
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.settings import Settings
|
||||
@@ -50,6 +49,22 @@ from backend.app.services.bambu_cloud import (
|
||||
BambuCloudService,
|
||||
invalidate_validation_cache,
|
||||
)
|
||||
|
||||
# Credential read/write lives in the services layer so feature packages can
|
||||
# consume it without importing the route layer. Imported here for this
|
||||
# module's own use; consumers should import from bambu_cloud_credentials
|
||||
# directly rather than through this route module.
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
CLOUD_EMAIL_KEY,
|
||||
CLOUD_REGION_KEY,
|
||||
CLOUD_TOKEN_INVALID_KEY,
|
||||
CLOUD_TOKEN_KEY,
|
||||
_clear_cloud_token_invalid,
|
||||
_normalise_region,
|
||||
get_stored_token,
|
||||
is_cloud_token_invalid,
|
||||
mark_cloud_token_invalid,
|
||||
)
|
||||
from backend.app.utils.filament_ids import filament_id_to_setting_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -166,100 +181,6 @@ async def resolve_api_key_cloud_owner(
|
||||
router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
|
||||
|
||||
|
||||
# Keys for storing cloud credentials in settings
|
||||
CLOUD_TOKEN_KEY = "bambu_cloud_token"
|
||||
CLOUD_EMAIL_KEY = "bambu_cloud_email"
|
||||
CLOUD_REGION_KEY = "bambu_cloud_region"
|
||||
# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
|
||||
# an ISO timestamp; absent/empty means "not known to be dead".
|
||||
CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
|
||||
|
||||
|
||||
def _normalise_region(region: str | None) -> str:
|
||||
"""Treat NULL/empty as 'global' for legacy rows that predate the region column."""
|
||||
return region if region in ("global", "china") else "global"
|
||||
|
||||
|
||||
async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
|
||||
"""Whether the stored Bambu token is known to have been rejected.
|
||||
|
||||
Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
|
||||
cleared on a fresh login/logout. This is the only durable record we have:
|
||||
Bambu's access token is opaque (no readable expiry) and Bambuddy does not
|
||||
persist the refresh token, so without this flag a dead credential looks
|
||||
exactly like a live one.
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token_invalid_at is not None
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
return bool(row and row.value)
|
||||
|
||||
|
||||
async def mark_cloud_token_invalid(user_id: int | None) -> None:
|
||||
"""Record that Bambu rejected the stored token.
|
||||
|
||||
Opens its own session on purpose. This runs from
|
||||
``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
|
||||
is about to fail — writing through that route's session would tie the flag
|
||||
to a transaction the route may still roll back, and the fact that the
|
||||
credential is dead is true regardless of how the request ends.
|
||||
|
||||
Best-effort: a bookkeeping failure must never replace the 401 the caller
|
||||
actually needs to see.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
if user_id is not None:
|
||||
await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
|
||||
else:
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = now.isoformat()
|
||||
else:
|
||||
db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
|
||||
await db.commit()
|
||||
logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
|
||||
except Exception:
|
||||
logger.exception("Could not record the Bambu Cloud token as invalid")
|
||||
|
||||
|
||||
async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
|
||||
"""Clear the rejected-token flag — called on every fresh login and logout."""
|
||||
if user is not None:
|
||||
await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
|
||||
return
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
await db.delete(row)
|
||||
|
||||
|
||||
async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
|
||||
"""Get stored cloud token, email, and region.
|
||||
|
||||
When a user is provided (auth enabled), returns that user's per-user credentials.
|
||||
When user is None (auth disabled), falls back to global Settings table.
|
||||
Region defaults to ``"global"`` when unset (including for rows that predate
|
||||
the ``cloud_region`` column).
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
|
||||
|
||||
# Fallback: global storage (auth disabled)
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
|
||||
)
|
||||
settings = {s.key: s.value for s in result.scalars().all()}
|
||||
return (
|
||||
settings.get(CLOUD_TOKEN_KEY),
|
||||
settings.get(CLOUD_EMAIL_KEY),
|
||||
_normalise_region(settings.get(CLOUD_REGION_KEY)),
|
||||
)
|
||||
|
||||
|
||||
async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
|
||||
"""Store cloud token, email, and region.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
@@ -239,11 +239,14 @@ async def delete_icon(
|
||||
async def get_icon(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
_: User | None = Depends(require_media_token_permission(Permission.EXTERNAL_LINKS_READ)),
|
||||
):
|
||||
"""Get the custom icon for an external link.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
the same ``external_links:read`` every other read on this router takes.
|
||||
Previously it took the camera-stream token, so a sidebar icon was visible
|
||||
only to users who could also watch the printer camera (#3025).
|
||||
"""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
@@ -41,6 +41,7 @@ from backend.app.services.finance_balance import (
|
||||
calculate_personal_balance,
|
||||
is_personal_transaction,
|
||||
personal_balance_condition,
|
||||
resolve_configured_currency,
|
||||
sync_personal_wallet_balance,
|
||||
)
|
||||
from backend.app.services.finance_budget import get_cost_center_reserved_map
|
||||
@@ -251,7 +252,7 @@ async def _get_or_create_wallet(db: AsyncSession, user_id: int) -> UserWallet:
|
||||
if wallet:
|
||||
return wallet
|
||||
|
||||
wallet = UserWallet(user_id=user_id, balance=0.0, currency="EUR")
|
||||
wallet = UserWallet(user_id=user_id, balance=0.0)
|
||||
db.add(wallet)
|
||||
await db.flush()
|
||||
await db.refresh(wallet)
|
||||
@@ -276,24 +277,31 @@ async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> Cost
|
||||
return center
|
||||
|
||||
|
||||
def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
|
||||
def _to_balance_response(wallet: UserWallet, currency: str) -> WalletBalanceResponse:
|
||||
"""Serialize a wallet, reporting the install's configured currency.
|
||||
|
||||
The wallet row holds no currency of its own: an install has exactly one,
|
||||
and an admin who changes it expects every balance to follow, the way the
|
||||
rest of the app does (#3123).
|
||||
"""
|
||||
return WalletBalanceResponse(
|
||||
user_id=wallet.user_id,
|
||||
balance=wallet.balance,
|
||||
currency=wallet.currency,
|
||||
currency=currency,
|
||||
updated_at=wallet.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_wallet_balance_read_only(db: AsyncSession, user_id: int) -> WalletBalanceResponse:
|
||||
"""Return a balance without creating a wallet row from a GET request."""
|
||||
currency = await resolve_configured_currency(db)
|
||||
wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
|
||||
if wallet is not None:
|
||||
return _to_balance_response(wallet)
|
||||
return _to_balance_response(wallet, currency)
|
||||
return WalletBalanceResponse(
|
||||
user_id=user_id,
|
||||
balance=await calculate_personal_balance(db, user_id),
|
||||
currency="EUR",
|
||||
currency=currency,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
@@ -386,15 +394,16 @@ async def _create_wallet_adjustment(
|
||||
await db.refresh(tx)
|
||||
|
||||
# Return appropriate balance based on transaction type
|
||||
currency = await resolve_configured_currency(db)
|
||||
if affects_personal_wallet:
|
||||
# Personal transaction: return user wallet balance
|
||||
response_balance = _to_balance_response(wallet)
|
||||
response_balance = _to_balance_response(wallet, currency)
|
||||
else:
|
||||
# Cost-center transaction: return cost-center balance as if it were a wallet
|
||||
response_balance = WalletBalanceResponse(
|
||||
user_id=target_user_id,
|
||||
balance=balance_after,
|
||||
currency=wallet.currency,
|
||||
currency=currency,
|
||||
updated_at=tx.created_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ from backend.app.schemas.spool import (
|
||||
normalize_extra_colors,
|
||||
)
|
||||
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
|
||||
from backend.app.services.ams_slot_presence import spool_present
|
||||
from backend.app.services.location_service import (
|
||||
DUPLICATE_LOCATION_NAME,
|
||||
assign_location_name,
|
||||
@@ -66,6 +67,7 @@ from backend.app.services.spool_csv import (
|
||||
)
|
||||
from backend.app.services.spool_filament_preset import resolve_spool_preset
|
||||
from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
|
||||
from backend.app.services.tag_conflict import tag_already_linked
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
filament_id_to_setting_id,
|
||||
@@ -1850,6 +1852,9 @@ async def assign_spool(
|
||||
fingerprint_type = None
|
||||
current_tray_info_idx = ""
|
||||
tray_state: int | None = None
|
||||
# Firmware's tray_exist_bits answer for this slot, when the payload carries
|
||||
# one. Outranks tray_state below — see services/ams_slot_presence.py.
|
||||
tray_has_spool: bool | None = None
|
||||
state = printer_manager.get_status(data.printer_id)
|
||||
if state and state.raw_data:
|
||||
if data.ams_id == 255:
|
||||
@@ -1864,6 +1869,7 @@ async def assign_spool(
|
||||
raw_state = vt.get("state")
|
||||
if isinstance(raw_state, int):
|
||||
tray_state = raw_state
|
||||
tray_has_spool = spool_present(vt)
|
||||
break
|
||||
else:
|
||||
ams_data = state.raw_data.get("ams", {})
|
||||
@@ -1886,6 +1892,7 @@ async def assign_spool(
|
||||
raw_state = tray.get("state")
|
||||
if isinstance(raw_state, int):
|
||||
tray_state = raw_state
|
||||
tray_has_spool = spool_present(tray)
|
||||
|
||||
# 3. Upsert assignment (replace if same printer+ams+tray)
|
||||
existing = await db.execute(
|
||||
@@ -1945,7 +1952,30 @@ async def assign_spool(
|
||||
# a doomed MQTT push when the firmware has positively confirmed "no
|
||||
# spool" — and to keep the on_ams_change replay path as the single
|
||||
# source of truth for those slots.
|
||||
slot_is_definitely_empty = tray_state == 9 or tray_state == 10
|
||||
#
|
||||
# ...except that `state` cannot carry that meaning. Two independent ways
|
||||
# a loaded slot reads 9 here:
|
||||
#
|
||||
# - an AMS-HT reports its LOADED tray as 9, not 11, because it does not
|
||||
# feed into a shared buffer the way a 4-slot AMS does (#2594, and the
|
||||
# merge above skips its own state heuristic for HT units for exactly
|
||||
# this reason). So this branch called every HT slot empty on sight.
|
||||
# - apply_tray_exist_bits stamps state=9 on any slot whose tray_exist_bits
|
||||
# bit is 0 and never takes it back when the bit returns, so a slot that
|
||||
# was briefly emptied keeps the 9 until something configures it.
|
||||
#
|
||||
# Either way the slot sits at exists=True, state=9, this branch took the
|
||||
# pending path, nothing was published, and the printer kept showing "?"
|
||||
# (#3084 — reported against an H2C's AMS-HT, where both apply). Firmware's
|
||||
# presence bit is what actually answers "is a spool in this slot", and the
|
||||
# printer card has read it ahead of `state` since #2527.
|
||||
#
|
||||
# It is allowed to overrule the 9 and nothing else. A bit reading *empty*
|
||||
# deliberately does NOT start suppressing pushes that go out today: the
|
||||
# cost of being wrong there is a slot that silently stops configuring, on
|
||||
# whichever AMS variant we compute the bit position wrong for, against a
|
||||
# saving of one MQTT message the firmware would have dropped anyway.
|
||||
slot_is_definitely_empty = tray_has_spool is not True and (tray_state == 9 or tray_state == 10)
|
||||
configured = False
|
||||
if not slot_is_definitely_empty:
|
||||
try:
|
||||
@@ -2082,7 +2112,12 @@ async def link_tag_to_spool(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Link an RFID tag_uid/tray_uuid to an existing spool."""
|
||||
"""Link an RFID tag_uid/tray_uuid to an existing spool.
|
||||
|
||||
A tag another active spool already carries is refused with the shared
|
||||
``tag_already_linked`` 409, which names that spool so a caller can offer
|
||||
to move the tag instead of only reporting that it is taken (#3110).
|
||||
"""
|
||||
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
|
||||
spool = result.scalar_one_or_none()
|
||||
if not spool:
|
||||
@@ -2096,17 +2131,30 @@ async def link_tag_to_spool(
|
||||
_validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
|
||||
_validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
|
||||
|
||||
# Check for conflicts: tag already linked to another active spool
|
||||
# Check for conflicts: tag already linked to another active spool.
|
||||
#
|
||||
# Ordered, and read with first() rather than scalar_one_or_none(), because
|
||||
# two active spools really can carry one tag: neither column has a unique
|
||||
# index, PATCH /spools/{id} writes them with no conflict check, and
|
||||
# POST /spools/bulk copies a single payload -- tag included -- into every
|
||||
# row it creates. scalar_one_or_none() answered that with MultipleResultsFound,
|
||||
# which escapes into the auth middleware's fail-closed handler and reaches
|
||||
# the caller as 503 "Authentication service temporarily unavailable" -- a
|
||||
# wrong answer pointing at the wrong subsystem, where a 409 was owed
|
||||
# (#3110). get_spool_by_tag above already resolves duplicates this way.
|
||||
if normalized_tag_uid:
|
||||
conflict = await db.execute(
|
||||
select(Spool).where(
|
||||
select(Spool)
|
||||
.where(
|
||||
func.upper(Spool.tag_uid) == normalized_tag_uid,
|
||||
Spool.id != spool_id,
|
||||
Spool.archived_at.is_(None),
|
||||
)
|
||||
.order_by(Spool.id)
|
||||
)
|
||||
if conflict.scalar_one_or_none():
|
||||
raise HTTPException(409, "Tag UID already linked to another active spool")
|
||||
holder = conflict.scalars().first()
|
||||
if holder:
|
||||
raise tag_already_linked("tag_uid", holder.id)
|
||||
# Auto-clear from archived spools (tag recycling)
|
||||
archived_with_tag = await db.execute(
|
||||
select(Spool).where(
|
||||
@@ -2120,14 +2168,17 @@ async def link_tag_to_spool(
|
||||
|
||||
if normalized_tray_uuid:
|
||||
conflict = await db.execute(
|
||||
select(Spool).where(
|
||||
select(Spool)
|
||||
.where(
|
||||
func.upper(Spool.tray_uuid) == normalized_tray_uuid,
|
||||
Spool.id != spool_id,
|
||||
Spool.archived_at.is_(None),
|
||||
)
|
||||
.order_by(Spool.id)
|
||||
)
|
||||
if conflict.scalar_one_or_none():
|
||||
raise HTTPException(409, "Tray UUID already linked to another active spool")
|
||||
holder = conflict.scalars().first()
|
||||
if holder:
|
||||
raise tag_already_linked("tray_uuid", holder.id)
|
||||
archived_with_uuid = await db.execute(
|
||||
select(Spool).where(
|
||||
func.upper(Spool.tray_uuid) == normalized_tray_uuid,
|
||||
|
||||
+172
-164
@@ -1,5 +1,6 @@
|
||||
"""API routes for File Manager (Library) functionality."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
@@ -21,8 +22,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
|
||||
from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
|
||||
from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
|
||||
from backend.app.core.auth import (
|
||||
RequireCameraStreamTokenIfAuthEnabled,
|
||||
require_media_token_ownership,
|
||||
require_ownership_permission,
|
||||
require_permission_if_auth_enabled,
|
||||
)
|
||||
@@ -33,6 +36,7 @@ from backend.app.core.tasks import spawn_background_task
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.library import (
|
||||
@@ -86,6 +90,7 @@ from backend.app.utils.filename import (
|
||||
safe_path_component,
|
||||
validate_print_filename,
|
||||
)
|
||||
from backend.app.utils.printer_models import is_gcode_compatible
|
||||
from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
|
||||
from backend.app.utils.threemf_tools import (
|
||||
carries_gcode,
|
||||
@@ -94,6 +99,7 @@ from backend.app.utils.threemf_tools import (
|
||||
extract_embedded_presets_from_3mf,
|
||||
extract_nozzle_mapping_from_3mf,
|
||||
extract_project_filaments_from_3mf,
|
||||
sanitize_project_settings_sentinels,
|
||||
select_plate_gcode_name,
|
||||
supports_enabled_in_config,
|
||||
)
|
||||
@@ -2832,14 +2838,61 @@ async def add_files_to_queue(
|
||||
|
||||
Only sliced files (.gcode or .gcode.3mf) can be added to the queue.
|
||||
The archive will be created automatically when the print starts.
|
||||
|
||||
A caller may name a printer or a target model for the whole batch; with
|
||||
neither, each file is aimed at the model it says it was sliced for. The
|
||||
gates are the ones ``POST /queue/`` applies to a single item, because an
|
||||
item that reaches the scheduler through this route has to be as printable
|
||||
as one that reaches it through that one (#3112).
|
||||
"""
|
||||
added: list[AddToQueueResult] = []
|
||||
errors: list[AddToQueueError] = []
|
||||
|
||||
# Batch-level targeting. Rejected outright rather than per file: the whole
|
||||
# request names one destination, so a bad one is not a property of any
|
||||
# single file and reporting it fourteen times would say nothing extra.
|
||||
target_model_norm = normalize_model_name(request.target_model)
|
||||
if request.printer_id is not None and target_model_norm:
|
||||
raise HTTPException(400, "Cannot specify both printer_id and target_model")
|
||||
|
||||
if request.printer_id is not None:
|
||||
printer_row = (await db.execute(select(Printer).where(Printer.id == request.printer_id))).scalar_one_or_none()
|
||||
if not printer_row:
|
||||
raise HTTPException(400, "Printer not found")
|
||||
|
||||
# Active printers of every model, read once, and only when the batch has no
|
||||
# printer of its own -- with one named, neither the check below nor the
|
||||
# inference in the loop consults it. The explicit target is validated for
|
||||
# the same reason POST /queue/ validates: a model nobody owns is a queue
|
||||
# item that waits forever. The inferred target reads the same set and
|
||||
# silently declines when it finds nothing, because there, owning no such
|
||||
# printer is the user's situation rather than their mistake -- the file
|
||||
# still queues, as the unassigned row it has always been.
|
||||
active_models: set[str] = set()
|
||||
if request.printer_id is None:
|
||||
active_models = {
|
||||
model
|
||||
for (model,) in (
|
||||
await db.execute(select(Printer.model).where(Printer.is_active == True).distinct()) # noqa: E712
|
||||
).all()
|
||||
if model
|
||||
}
|
||||
if target_model_norm and target_model_norm not in active_models:
|
||||
raise HTTPException(400, f"No active printers for model: {target_model_norm}")
|
||||
|
||||
# Get all requested files
|
||||
result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
|
||||
files = {f.id: f for f in result.scalars().all()}
|
||||
|
||||
# Ownership-scoped reads apply here as everywhere else in this module: a
|
||||
# file the caller may not read is a file they may not print. Dropped from
|
||||
# the map rather than refused by name, so the per-file error below is the
|
||||
# same "File not found" an unknown id gets and the response says nothing
|
||||
# about which ids exist. Ownerless rows need LIBRARY_READ_ALL, matching
|
||||
# _ensure_library_file_visible.
|
||||
if current_user is not None and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value):
|
||||
files = {fid: f for fid, f in files.items() if f.created_by_id == current_user.id}
|
||||
|
||||
# Project attribution (#1897): a file queued from a project-linked folder
|
||||
# inherits that project, so the resulting archive counts toward the
|
||||
# project's progress. A file's own project link wins over its folder's.
|
||||
@@ -2883,10 +2936,66 @@ async def add_files_to_queue(
|
||||
)
|
||||
continue
|
||||
|
||||
# The Bambu SD card is FAT32/exFAT, so an illegal character 553s at
|
||||
# upload time. POST /queue/ rejects those at queue time (#1540) and
|
||||
# this route did not, which turned a nameable mistake into a print
|
||||
# that failed hours later.
|
||||
try:
|
||||
validate_print_filename(lib_file.filename)
|
||||
except InvalidFilenameError as e:
|
||||
errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
|
||||
continue
|
||||
|
||||
# Where this file is aimed. An explicit printer wins; an explicit
|
||||
# model applies to every file and has to be one this file can
|
||||
# legally run on; with neither, the file's own declaration is used
|
||||
# when some active printer answers to it.
|
||||
item_printer_id = request.printer_id
|
||||
item_target_model: str | None = None
|
||||
if item_printer_id is None:
|
||||
if target_model_norm:
|
||||
sliced_for = (lib_file.file_metadata or {}).get("sliced_for_model")
|
||||
if not is_gcode_compatible(sliced_for, target_model_norm):
|
||||
errors.append(
|
||||
AddToQueueError(
|
||||
file_id=file_id,
|
||||
filename=lib_file.filename,
|
||||
error=(
|
||||
f"File was sliced for {sliced_for} and cannot be dispatched to "
|
||||
f"{target_model_norm} printers"
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
item_target_model = target_model_norm
|
||||
else:
|
||||
inferred = resolve_variant_model(lib_file)
|
||||
item_target_model = inferred if inferred in active_models else None
|
||||
|
||||
# Filament the scheduler must match before handing a model-based
|
||||
# item to hardware. Without it the item goes to whichever printer
|
||||
# of that model is idle, whatever is loaded in it.
|
||||
required_filament_types = None
|
||||
if item_target_model:
|
||||
# POST /queue/'s own extractor, borrowed rather than
|
||||
# reimplemented: a second copy of this rule is a second thing
|
||||
# to keep in step.
|
||||
#
|
||||
# Off the loop, unlike there: that route parses one 3MF per
|
||||
# request and this one parses every file in the batch, so on a
|
||||
# bulk add of a few hundred -- especially from an external
|
||||
# folder on a NAS -- the zip reads add up to a stall the whole
|
||||
# event loop takes, status ingest included.
|
||||
filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path)
|
||||
if filament_types:
|
||||
required_filament_types = json.dumps(filament_types)
|
||||
|
||||
# Create queue item referencing library file (archive created at print start)
|
||||
max_position += 1
|
||||
queue_item = PrintQueueItem(
|
||||
printer_id=None, # Unassigned
|
||||
printer_id=item_printer_id,
|
||||
target_model=item_target_model,
|
||||
required_filament_types=required_filament_types,
|
||||
library_file_id=file_id,
|
||||
project_id=lib_file.project_id
|
||||
or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
|
||||
@@ -2913,6 +3022,20 @@ async def add_files_to_queue(
|
||||
logger.exception("Error adding file %s to queue", file_id)
|
||||
errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
|
||||
|
||||
# Nothing queued and something to say about why. Returning 200 here is what
|
||||
# made this look like a working call that quietly did nothing: a client that
|
||||
# checks the status code sees success, and the reasons sit in a body it had
|
||||
# no cause to read (#3112). Partial success stays 200 -- items really were
|
||||
# created, and the per-file errors belong with them.
|
||||
if not added and errors:
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"message": "No files could be added to the queue.",
|
||||
"errors": [e.model_dump() for e in errors],
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return AddToQueueResponse(added=added, errors=errors)
|
||||
@@ -3213,16 +3336,22 @@ async def get_library_file_plate_thumbnail(
|
||||
file_id: int,
|
||||
plate_index: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.LIBRARY_READ_ALL,
|
||||
Permission.LIBRARY_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get the thumbnail image for a specific plate from a library file."""
|
||||
"""Get the thumbnail image for a specific plate from a library file.
|
||||
|
||||
Ownership-gated on the same terms as the file itself (#3025).
|
||||
"""
|
||||
from starlette.responses import Response
|
||||
|
||||
user, can_read_all = auth_result
|
||||
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
|
||||
lib_file = result.scalar_one_or_none()
|
||||
|
||||
if not lib_file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
lib_file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
|
||||
|
||||
file_path = Path(app_settings.base_dir) / lib_file.file_path
|
||||
if not file_path.exists():
|
||||
@@ -3486,142 +3615,6 @@ async def get_library_file_filament_requirements(
|
||||
}
|
||||
|
||||
|
||||
_STRIPPABLE_3MF_CONFIGS = frozenset(
|
||||
{
|
||||
# Settings dump used by --load-settings validation; the CLI tries to
|
||||
# match its sentinel values (`prime_tower_brim_width: -1`, empty
|
||||
# arrays) against the supplied profile and rejects out-of-range.
|
||||
"Metadata/project_settings.config",
|
||||
# Per-object settings overrides referencing the source plate's
|
||||
# filament IDs / printer IDs. When the user picks a different
|
||||
# printer / filament triplet, the IDs no longer resolve and the
|
||||
# CLI exits non-zero on input validation.
|
||||
"Metadata/model_settings.config",
|
||||
# Slicer-version + plate-config + filament-mapping snapshot from
|
||||
# the original slice. Includes the original printer model and
|
||||
# filament references; mismatches against `--load-settings`
|
||||
# consistently surfaced as `Slicer CLI failed (500)` for every
|
||||
# 3MF in production. Removing it lets the CLI build a fresh slice
|
||||
# plan from the supplied profile triplet.
|
||||
"Metadata/slice_info.config",
|
||||
# Multi-part / split-mesh metadata referencing object IDs from the
|
||||
# original slice. Strip for the same reason — preserves the geometry
|
||||
# in `3D/3dmodel.model` while dropping the orphan references.
|
||||
"Metadata/cut_information.xml",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _strip_3mf_embedded_settings(zip_bytes: bytes) -> bytes:
|
||||
"""Remove embedded slicer-config metadata from a 3MF.
|
||||
|
||||
Bambuddy supplies the slicer profile triplet via the sidecar's
|
||||
``--load-settings`` path; the 3MF's embedded settings would otherwise be
|
||||
validated by the CLI first and can fail with sentinel-value range
|
||||
checks (`prime_tower_brim_width: -1 not in range`, etc.) regardless of
|
||||
what we pass via ``--load-settings``. Stripping the embedded configs
|
||||
forces the CLI to use the supplied profiles only. Geometry
|
||||
(``3D/3dmodel.model``), thumbnails, color, and multi-part data inside
|
||||
the 3MF are preserved.
|
||||
|
||||
The set of strippable filenames is centralised in
|
||||
``_STRIPPABLE_3MF_CONFIGS`` — see that constant for the per-file
|
||||
rationale. Project-settings alone wasn't enough: real-world Bambu
|
||||
Studio 3MFs cross-reference printer / filament IDs from the other
|
||||
metadata configs, and any single leftover triggered the validation
|
||||
failure that made every profile-driven slice fall back to embedded
|
||||
settings.
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
src = BytesIO(zip_bytes)
|
||||
dst = BytesIO()
|
||||
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename in _STRIPPABLE_3MF_CONFIGS:
|
||||
continue
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
return dst.getvalue()
|
||||
|
||||
|
||||
# Keys in ``Metadata/project_settings.config`` that BambuStudio writes ``"-1"``
|
||||
# to when the user wants the value inherited from the parent process preset.
|
||||
# The CLI's ``StaticPrintConfig`` validator runs against the embedded settings
|
||||
# *before* ``--load-settings`` overrides apply, so a sentinel ``"-1"`` trips
|
||||
# the field's lower-bound range check and the CLI exits non-zero before our
|
||||
# profile triplet is ever consulted (#1201 — MakerWorld P2S models).
|
||||
#
|
||||
# Allowlisted (rather than "strip every '-1' value") because some fields
|
||||
# legitimately accept negative numbers (z_offset, translation values, etc.)
|
||||
# and a blanket strip would silently corrupt those.
|
||||
#
|
||||
# Add new entries here as more reports surface — the slicer's error message
|
||||
# names the offending field directly (`<field>: -1 not in range [...]`).
|
||||
_PROJECT_SETTINGS_SENTINEL_KEYS = frozenset(
|
||||
{
|
||||
# Reported in #1201 (MakerWorld P2S 3MFs).
|
||||
"raft_first_layer_expansion",
|
||||
"tree_support_wall_count",
|
||||
# Cited in the strip-experiment comment block above as a known sentinel
|
||||
# case from earlier reports.
|
||||
"prime_tower_brim_width",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
|
||||
"""Strip ``"-1"`` inherit-from-parent sentinels from the 3MF's
|
||||
``Metadata/project_settings.config`` so the slicer CLI's range validator
|
||||
accepts the file (#1201).
|
||||
|
||||
Removes only allowlisted keys (see ``_PROJECT_SETTINGS_SENTINEL_KEYS``)
|
||||
when their value is exactly ``"-1"``. The rest of the config — and every
|
||||
other entry in the zip — is preserved byte-for-byte. Unlike the earlier
|
||||
full-strip experiment (see ``_strip_3mf_embedded_settings`` and the
|
||||
cautionary comment in ``_run_slicer_with_fallback``) this leaves
|
||||
``StaticPrintConfig`` initialisation intact: the file is still present,
|
||||
still parses, and the slicer falls back to the supplied
|
||||
``--load-settings`` value for the removed key.
|
||||
|
||||
Returns the original bytes unchanged when no sanitisation is needed
|
||||
(input isn't a valid zip, no ``project_settings.config``, no allowlisted
|
||||
sentinels present, or any other parse failure) so the caller can pass
|
||||
the result on without further checks.
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
|
||||
if "Metadata/project_settings.config" not in zin.namelist():
|
||||
return zip_bytes
|
||||
try:
|
||||
config = json.loads(zin.read("Metadata/project_settings.config").decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return zip_bytes
|
||||
if not isinstance(config, dict):
|
||||
return zip_bytes
|
||||
removed = [key for key in _PROJECT_SETTINGS_SENTINEL_KEYS if config.get(key) == "-1"]
|
||||
if not removed:
|
||||
return zip_bytes
|
||||
for key in removed:
|
||||
config.pop(key, None)
|
||||
patched = json.dumps(config)
|
||||
logger.info(
|
||||
"3MF sanitiser: removed sentinel '-1' for keys %s — slicer will use --load-settings defaults",
|
||||
sorted(removed),
|
||||
)
|
||||
dst = BytesIO()
|
||||
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename == "Metadata/project_settings.config":
|
||||
zout.writestr(item, patched)
|
||||
else:
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
return dst.getvalue()
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return zip_bytes
|
||||
|
||||
|
||||
def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
|
||||
"""Overwrite ``curr_bed_type`` in a process-profile JSON before forwarding
|
||||
to the slicer sidecar.
|
||||
@@ -4016,13 +4009,14 @@ async def _run_slicer_with_fallback(
|
||||
is_3mf = model_filename.lower().endswith(".3mf")
|
||||
primary_bytes = model_bytes
|
||||
if is_3mf:
|
||||
# Strip "-1" inherit-from-parent sentinels from
|
||||
# Metadata/project_settings.config so the CLI's StaticPrintConfig
|
||||
# range validator accepts the file (#1201). Surgical — keeps the
|
||||
# config present, just removes the offending keys; the supplied
|
||||
# --load-settings (and the fallback's embedded values for keys we
|
||||
# didn't touch) still drive the slice.
|
||||
primary_bytes = _sanitize_project_settings_sentinels(primary_bytes)
|
||||
# Strip inherit/unset sentinels from Metadata/project_settings.config
|
||||
# so the CLI's StaticPrintConfig range validator accepts the file
|
||||
# (#1201, #3030). Surgical — keeps the config present, just removes
|
||||
# the offending keys; the supplied --load-settings (and the fallback's
|
||||
# embedded values for keys we didn't touch) still drive the slice.
|
||||
# The preview-slice path applies the same sanitiser in
|
||||
# ``slice_preview.get_preview_filaments``.
|
||||
primary_bytes = sanitize_project_settings_sentinels(primary_bytes)
|
||||
|
||||
# #2622: the process settings the file's designer moved off the stock
|
||||
# preset. Read once — the support patch below needs to know which of
|
||||
@@ -4549,8 +4543,10 @@ async def slice_and_persist(
|
||||
# BS/Orca CLIs skip plate_N.png in headless --export-3mf — render +
|
||||
# inject server-side so the library card has a thumbnail. Best-effort:
|
||||
# no-op when the slicer did embed thumbs (desktop Studio path), and
|
||||
# falls through to the unmodified bytes on any render error.
|
||||
result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
|
||||
# falls through to the unmodified bytes on any render error. In a thread:
|
||||
# a large plate renders for seconds, and on the event loop that stalled
|
||||
# every request and printer connection for as long (#3135).
|
||||
result = result._replace(content=await asyncio.to_thread(inject_plate_thumbnails_if_missing, result.content))
|
||||
out_path.write_bytes(result.content)
|
||||
|
||||
# Extract thumbnail from the produced 3MF so the library card shows a
|
||||
@@ -4697,8 +4693,9 @@ async def slice_and_persist_as_archive(
|
||||
# See library-slice path: BS/Orca sidecar CLIs don't embed plate_N.png
|
||||
# in headless --export-3mf, so the produced 3MF often has no thumbnail
|
||||
# at all. Server-side render fills the gap; no-op when the slicer did
|
||||
# embed (desktop Studio path) and best-effort on any render error.
|
||||
result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
|
||||
# embed (desktop Studio path) and best-effort on any render error. Off the
|
||||
# event loop, like the library-slice path (#3135).
|
||||
result = result._replace(content=await asyncio.to_thread(inject_plate_thumbnails_if_missing, result.content))
|
||||
out_path.write_bytes(result.content)
|
||||
|
||||
# Extract a thumbnail for the new archive card. Priority order:
|
||||
@@ -5264,13 +5261,15 @@ async def download_library_file_for_slicer(
|
||||
):
|
||||
"""Download a library file using a slicer download token.
|
||||
|
||||
Token-authenticated (no auth headers needed). The token is short-lived
|
||||
and single-use, created by POST /files/{file_id}/slicer-token.
|
||||
Token-authenticated (no auth headers needed). The token is short-lived and
|
||||
file-bound, created by POST /files/{file_id}/slicer-token, and redeemable
|
||||
for the rest of its TTL rather than exactly once -- the slicer is a separate
|
||||
process that may fetch the URL more than once (#3029).
|
||||
Filename is at the end of the URL so slicers can detect the file format.
|
||||
"""
|
||||
from backend.app.core.auth import verify_slicer_download_token
|
||||
|
||||
if not await verify_slicer_download_token(token, "library", file_id):
|
||||
if not await verify_slicer_download_token(token, "library", file_id, single_use=False):
|
||||
raise HTTPException(status_code=403, detail="Invalid or expired download token")
|
||||
|
||||
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
|
||||
@@ -5293,14 +5292,23 @@ async def download_library_file_for_slicer(
|
||||
async def get_thumbnail(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.LIBRARY_READ_ALL,
|
||||
Permission.LIBRARY_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get a file's thumbnail."""
|
||||
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
|
||||
file = result.scalar_one_or_none()
|
||||
"""Get a file's thumbnail.
|
||||
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
Accepts a media token in ``?token=`` because <img> cannot send headers.
|
||||
Ownership is enforced here rather than assumed from the credential: until
|
||||
#3025 this route took the anonymous camera-stream token, which carried no
|
||||
principal, so any holder could read any user's thumbnail by walking IDs.
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
|
||||
file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
|
||||
|
||||
abs_thumb_path = to_absolute_path(file.thumbnail_path)
|
||||
if not abs_thumb_path or not abs_thumb_path.exists():
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
"""MakerWorld integration routes.
|
||||
|
||||
User pastes a MakerWorld URL → Bambuddy resolves it → shows plate list →
|
||||
one-click import/print. The URL-paste flow covers the actual discovery
|
||||
pattern (Reddit/YouTube/shared links) without needing to replicate
|
||||
MakerWorld's whole search UI.
|
||||
User pastes a model URL (MakerWorld or other supported host) → Bambuddy resolves
|
||||
it → shows plate list → one-click import/print. The URL-paste flow covers the
|
||||
actual discovery pattern (Reddit/YouTube/shared links) without needing to
|
||||
replicate the host's whole search UI.
|
||||
|
||||
Search/browse endpoints are intentionally NOT exposed: the public-facing
|
||||
``design/search`` endpoint returns empty results from server-originated
|
||||
requests (see memory/makerworld-integration.md for the investigation).
|
||||
|
||||
These are still the *MakerWorld* routes: they consult the shared seams where
|
||||
one exists — URL routing via :class:`ModelProviderRegistry`, permissions and
|
||||
folder naming from the provider descriptor, already-imported matching via
|
||||
:meth:`ModelProvider.source_url_filter` — but request/response shapes remain
|
||||
MakerWorld-specific. The fully shared import API that makes new hosts work
|
||||
with zero route changes arrives with #2793.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,19 +23,20 @@ import logging
|
||||
import os
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import (
|
||||
get_stored_token,
|
||||
is_cloud_token_invalid,
|
||||
mark_cloud_token_invalid,
|
||||
resolve_api_key_cloud_owner,
|
||||
)
|
||||
from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
|
||||
from backend.app.api.routes.library import save_3mf_bytes_to_library
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.auth import (
|
||||
RequirePermissionIfAuthEnabled,
|
||||
require_auth_if_enabled,
|
||||
require_permission_if_auth_enabled,
|
||||
security,
|
||||
)
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
@@ -41,74 +49,119 @@ from backend.app.schemas.makerworld import (
|
||||
MakerWorldResolveRequest,
|
||||
MakerWorldStatus,
|
||||
)
|
||||
from backend.app.services.makerworld import (
|
||||
MakerWorldAuthError,
|
||||
MakerWorldError,
|
||||
MakerWorldForbiddenError,
|
||||
MakerWorldNotFoundError,
|
||||
MakerWorldService,
|
||||
MakerWorldUnavailableError,
|
||||
MakerWorldUrlError,
|
||||
from backend.app.services.model_providers import makerworld_provider, registry
|
||||
from backend.app.services.model_providers.base import (
|
||||
ModelProvider,
|
||||
ProviderAuthError,
|
||||
ProviderError,
|
||||
ProviderForbiddenError,
|
||||
ProviderNotFoundError,
|
||||
ProviderResourceRef,
|
||||
ProviderService,
|
||||
ProviderUnavailableError,
|
||||
ProviderUrlError,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld.service import MakerWorldService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/makerworld", tags=["makerworld"])
|
||||
|
||||
_SOURCE_TYPE = "makerworld"
|
||||
|
||||
def _provider_for_url(url: str) -> ModelProvider:
|
||||
"""Return the registered model provider that claims *url*.
|
||||
|
||||
async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldService:
|
||||
"""Construct a per-request MakerWorldService seeded with the caller's
|
||||
stored Bambu Cloud bearer token when available.
|
||||
|
||||
Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
|
||||
optional; anonymous calls (metadata, URL resolution) still work — and,
|
||||
like it, records a rejected token so the whole app agrees the sign-in is
|
||||
dead rather than each feature failing on its own.
|
||||
A pasted link for an unsupported host is a clean 400 — the registry is
|
||||
the routing seam, and "nobody supports this URL" is a client-input
|
||||
problem, not a server error.
|
||||
"""
|
||||
token, _email, _region = await get_stored_token(db, user)
|
||||
user_id = user.id if user is not None else None
|
||||
return MakerWorldService(
|
||||
auth_token=token,
|
||||
on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
|
||||
)
|
||||
provider = registry.find_for_url(url)
|
||||
if provider is None:
|
||||
msg = f"No registered model provider supports {url!r}"
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
return provider
|
||||
|
||||
|
||||
def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
|
||||
"""Build a stable source_url we use for dedupe.
|
||||
def _provider_for_source(source_type: str) -> ModelProvider:
|
||||
"""Return the registered model provider with this ``source_type``.
|
||||
|
||||
Dedupe is keyed per *plate* (profile) rather than per model, since the
|
||||
``/iot-service/.../profile/{profileId}`` download returns a specific
|
||||
plate — not the full multi-plate zip — so two different plates of the
|
||||
same design should become two separate library entries. Canonical
|
||||
shape uses the locale-free path with the ``#profileId-`` fragment so
|
||||
all URL variants of the same plate still collapse (e.g. ``/en/models/
|
||||
123-slug?from=search#profileId-456`` and ``/de/models/123#profileId-
|
||||
456`` both map to ``https://makerworld.com/models/123#profileId-
|
||||
456``). Plate-less imports (legacy or whole-design) keep the old
|
||||
model-only shape for backwards compatibility with existing rows.
|
||||
Import identifies a resource by numeric id, not by URL, so there is
|
||||
nothing to route on except the source type the caller names. The detail
|
||||
is built here rather than via ``str(KeyError)`` — KeyError's ``__str__``
|
||||
is the *repr* of its argument and would ship the quotes to the client.
|
||||
"""
|
||||
if profile_id:
|
||||
return f"https://makerworld.com/models/{model_id}#profileId-{profile_id}"
|
||||
return f"https://makerworld.com/models/{model_id}"
|
||||
try:
|
||||
return registry.get(source_type)
|
||||
except KeyError as exc:
|
||||
msg = f"No model provider registered for source_type {source_type!r}"
|
||||
raise HTTPException(status_code=400, detail=msg) from exc
|
||||
|
||||
|
||||
def _map_service_error(exc: MakerWorldError) -> HTTPException:
|
||||
"""Translate service exceptions into HTTP responses."""
|
||||
if isinstance(exc, MakerWorldUrlError):
|
||||
async def _authorize_for_provider(
|
||||
provider: ModelProvider,
|
||||
permission: Permission | None,
|
||||
credentials: HTTPAuthorizationCredentials | None,
|
||||
x_api_key: str | None,
|
||||
) -> User | None:
|
||||
"""Apply *provider*'s own permission to a request that named it.
|
||||
|
||||
This cannot live in the route signature. FastAPI resolves dependencies
|
||||
before the body exists, so a dependency can only ever bake in one
|
||||
provider's permission — MakerWorld's — while the provider actually being
|
||||
used comes from the request (``source_type`` on import, the pasted URL on
|
||||
resolve). Importing from a second provider would then be gated on
|
||||
``makerworld:import``, which is nobody's intent.
|
||||
|
||||
The check runs through the same ``require_permission_if_auth_enabled``
|
||||
the decorator would have built, so JWT users, API keys (scope gate plus
|
||||
the owner-outranks-key rule) and auth-disabled installs behave exactly as
|
||||
before. The routes keep a permission-free ``require_auth_if_enabled``
|
||||
dependency so an anonymous caller is still refused before the body is
|
||||
read.
|
||||
|
||||
A provider that declares no permission is refused rather than waved
|
||||
through: the descriptor's permission fields are optional, and "unset"
|
||||
must not read as "unrestricted".
|
||||
"""
|
||||
if permission is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Model provider {provider.source_type!r} declares no permission for this operation",
|
||||
)
|
||||
checker = require_permission_if_auth_enabled(permission)
|
||||
return await checker(credentials=credentials, x_api_key=x_api_key)
|
||||
|
||||
|
||||
async def _build_service(
|
||||
db: AsyncSession,
|
||||
provider: ModelProvider,
|
||||
current_user: User | None,
|
||||
api_key_cloud_owner: User | None = None,
|
||||
) -> ProviderService:
|
||||
"""Construct a per-request service via *provider*.
|
||||
|
||||
Identity resolution (JWT user vs API-key owner vs anonymous) and
|
||||
credential seeding live inside ``provider.build_service`` — the single
|
||||
place every provider resolves them, so the routes never re-implement it.
|
||||
"""
|
||||
return await provider.build_service(db=db, user=current_user, api_key_owner=api_key_cloud_owner)
|
||||
|
||||
|
||||
def _map_service_error(exc: ProviderError) -> HTTPException:
|
||||
"""Translate provider service exceptions into HTTP responses."""
|
||||
if isinstance(exc, ProviderUrlError):
|
||||
return HTTPException(status_code=400, detail=str(exc))
|
||||
if isinstance(exc, MakerWorldAuthError):
|
||||
if isinstance(exc, ProviderAuthError):
|
||||
return HTTPException(status_code=401, detail=str(exc))
|
||||
if isinstance(exc, MakerWorldForbiddenError):
|
||||
# 403 forwards MakerWorld's own refusal message (content-gated,
|
||||
if isinstance(exc, ProviderForbiddenError):
|
||||
# 403 forwards the provider's own refusal message (content-gated,
|
||||
# region-locked, requires points, etc.) — UI surfaces it verbatim.
|
||||
return HTTPException(status_code=403, detail=str(exc))
|
||||
if isinstance(exc, MakerWorldNotFoundError):
|
||||
if isinstance(exc, ProviderNotFoundError):
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
if isinstance(exc, MakerWorldUnavailableError):
|
||||
if isinstance(exc, ProviderUnavailableError):
|
||||
return HTTPException(status_code=502, detail=str(exc))
|
||||
return HTTPException(status_code=500, detail=f"MakerWorld error: {exc}")
|
||||
return HTTPException(status_code=500, detail=f"Model provider error: {exc}")
|
||||
|
||||
|
||||
@router.get("/thumbnail")
|
||||
@@ -133,10 +186,10 @@ async def proxy_thumbnail(
|
||||
URLs are content-addressable (filename contains a hash), so the
|
||||
aggressive ``immutable`` cache-control is safe.
|
||||
"""
|
||||
service = MakerWorldService()
|
||||
service = MakerWorldService(thumbnail_hosts=makerworld_provider.thumbnail_hosts())
|
||||
try:
|
||||
payload, content_type = await service.fetch_thumbnail(url)
|
||||
except MakerWorldError as exc:
|
||||
except ProviderError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
finally:
|
||||
await service.close()
|
||||
@@ -153,7 +206,7 @@ async def proxy_thumbnail(
|
||||
@router.get("/status", response_model=MakerWorldStatus)
|
||||
async def get_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
|
||||
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
|
||||
):
|
||||
"""Report whether the caller can import 3MFs (needs a Bambu Cloud token).
|
||||
@@ -164,26 +217,37 @@ async def get_status(
|
||||
stored token rather than always reporting ``False`` (#1777, same shape
|
||||
as the cloud-presets fix in #1182).
|
||||
"""
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
token, _email, _region = await get_stored_token(db, cloud_token_user)
|
||||
has_token = bool(token)
|
||||
# A token Bambu has already rejected downloads nothing. ``can_download``
|
||||
# used to be a bare alias for ``has_cloud_token``, so the import button
|
||||
# stayed enabled against a dead credential and the user found out via a
|
||||
# 401 toast (#2562 follow-up).
|
||||
expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
|
||||
service = await _build_service(db, makerworld_provider, current_user, api_key_cloud_owner)
|
||||
try:
|
||||
status = await service.get_status(db)
|
||||
finally:
|
||||
await service.close()
|
||||
return MakerWorldStatus(
|
||||
has_cloud_token=has_token,
|
||||
can_download=has_token and not expired,
|
||||
sign_in_expired=expired,
|
||||
has_cloud_token=status.authenticated,
|
||||
can_download=status.can_download,
|
||||
# ``credential_rejected`` is the machine-readable "your sign-in
|
||||
# expired" state the provider set exactly when a stored token exists
|
||||
# *and* was rejected — no token means there is no sign-in to have
|
||||
# expired. It is read instead of ``auth_error is not None`` because
|
||||
# the latter is a human-readable reason that providers may also set
|
||||
# for non-credential failures (network, rate limit).
|
||||
sign_in_expired=status.credential_rejected,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/resolve", response_model=MakerWorldResolvedModel)
|
||||
@router.post(
|
||||
"/resolve",
|
||||
response_model=MakerWorldResolvedModel,
|
||||
# Authentication only — the permission belongs to whichever provider the
|
||||
# pasted URL routes to, which is not known until the body is parsed (see
|
||||
# ``_authorize_for_provider``).
|
||||
dependencies=[Depends(require_auth_if_enabled)],
|
||||
)
|
||||
async def resolve_url(
|
||||
body: MakerWorldResolveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
|
||||
):
|
||||
"""Resolve a MakerWorld URL to full model metadata + plate list.
|
||||
@@ -192,68 +256,34 @@ async def resolve_url(
|
||||
exist for the same model URL, so the UI can show an "Already imported"
|
||||
badge and skip a redundant download.
|
||||
"""
|
||||
# Strategy pattern: select provider based on URL instead of hardcoding.
|
||||
# Routing runs before the permission check because the permission *is* the
|
||||
# provider's; all an unpermitted caller learns from the ordering is which
|
||||
# hosts Bambuddy supports, which the UI states anyway.
|
||||
provider = _provider_for_url(body.url)
|
||||
current_user = await _authorize_for_provider(provider, provider.view_permission, credentials, x_api_key)
|
||||
try:
|
||||
model_id, profile_id = MakerWorldService.parse_url(body.url)
|
||||
except MakerWorldError as exc:
|
||||
ref = provider.parse_url(body.url)
|
||||
except ProviderError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
model_id = int(ref.external_id)
|
||||
profile_id = int(ref.sub_id) if ref.sub_id else None
|
||||
|
||||
# API-keyed callers carry identity on the key, not in current_user — see
|
||||
# the /status handler comment and #1777 / #1182.
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
service = await _build_service(db, cloud_token_user)
|
||||
service = await _build_service(db, provider, current_user, api_key_cloud_owner)
|
||||
try:
|
||||
design = await service.get_design(model_id)
|
||||
instances_envelope = await service.get_design_instances(model_id)
|
||||
except MakerWorldError as exc:
|
||||
resolved = await service.resolve(ref)
|
||||
except ProviderError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
# MakerWorld's instances payload is ``{"total": N, "hits": [...]}``; callers
|
||||
# only care about the hits, and we normalise the null case to an empty list
|
||||
# so the frontend doesn't have to handle null vs [] both ways.
|
||||
instances = instances_envelope.get("hits") or []
|
||||
if not isinstance(instances, list):
|
||||
instances = []
|
||||
|
||||
# /instances/hits omits the per-instance printer compatibility info that
|
||||
# /design.instances[].extention.modelInfo carries (compatibility +
|
||||
# otherCompatibility). Merge it in so the frontend can show "this
|
||||
# instance was sliced for A1" + "also marked compatible with: H2D, P1S,
|
||||
# …" before the user picks one — without that, every instance row looks
|
||||
# identical in the UI and users blindly pick the first one regardless of
|
||||
# whether it matches their printer.
|
||||
design_instances = design.get("instances") or []
|
||||
if isinstance(design_instances, list):
|
||||
compat_by_id = {}
|
||||
for di in design_instances:
|
||||
if not isinstance(di, dict):
|
||||
continue
|
||||
iid = di.get("id")
|
||||
if iid is None:
|
||||
continue
|
||||
ext = (di.get("extention") or {}).get("modelInfo") or {}
|
||||
compat_by_id[iid] = {
|
||||
"compatibility": ext.get("compatibility"),
|
||||
"otherCompatibility": ext.get("otherCompatibility"),
|
||||
}
|
||||
for inst in instances:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
iid = inst.get("id")
|
||||
extra = compat_by_id.get(iid)
|
||||
if extra:
|
||||
inst["compatibility"] = extra["compatibility"]
|
||||
inst["otherCompatibility"] = extra["otherCompatibility"]
|
||||
|
||||
# Find every library row whose source_url is either the model-level
|
||||
# canonical URL (legacy whole-model imports) or any plate-level URL
|
||||
# (``...#profileId-{n}``) under this model. The frontend surfaces this
|
||||
# Find every library row whose source_url belongs to this resource —
|
||||
# the provider's :meth:`source_url_filter` owns what "belongs" means
|
||||
# (whole-model key, per-plate keys, ...). The frontend surfaces the ids
|
||||
# to mark imported plates in the instance picker.
|
||||
model_prefix = _canonical_url(model_id)
|
||||
existing_q = await db.execute(
|
||||
select(LibraryFile.id).where(
|
||||
(LibraryFile.source_url == model_prefix) | (LibraryFile.source_url.like(f"{model_prefix}#profileId-%")),
|
||||
provider.source_url_filter(LibraryFile.source_url, str(model_id)),
|
||||
LibraryFile.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
@@ -262,17 +292,24 @@ async def resolve_url(
|
||||
return MakerWorldResolvedModel(
|
||||
model_id=model_id,
|
||||
profile_id=profile_id,
|
||||
design=design,
|
||||
instances=instances,
|
||||
design=resolved.design,
|
||||
instances=resolved.instances,
|
||||
already_imported_library_ids=already_imported,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import", response_model=MakerWorldImportResponse)
|
||||
@router.post(
|
||||
"/import",
|
||||
response_model=MakerWorldImportResponse,
|
||||
# Authentication only — the permission belongs to the provider named by
|
||||
# ``source_type`` (see ``_authorize_for_provider``).
|
||||
dependencies=[Depends(require_auth_if_enabled)],
|
||||
)
|
||||
async def import_instance(
|
||||
body: MakerWorldImportRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
|
||||
):
|
||||
"""Download a specific MakerWorld instance (plate configuration) and save
|
||||
@@ -282,6 +319,15 @@ async def import_instance(
|
||||
was imported before (any plate), that existing LibraryFile is returned and
|
||||
no new download happens.
|
||||
"""
|
||||
# Resolve the provider first: an unknown ``source_type`` must 400 before
|
||||
# the default-destination folder gets auto-created as a side effect — and
|
||||
# the permission that applies is the resolved provider's, not MakerWorld's,
|
||||
# so it cannot be checked any earlier. All that costs is telling an
|
||||
# authenticated-but-unpermitted caller which source types are registered,
|
||||
# which the UI lists anyway; anonymous callers never get this far.
|
||||
provider = _provider_for_source(body.source_type)
|
||||
current_user = await _authorize_for_provider(provider, provider.import_permission, credentials, x_api_key)
|
||||
|
||||
if body.folder_id is not None:
|
||||
folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
|
||||
target_folder = folder_q.scalar_one_or_none()
|
||||
@@ -294,90 +340,78 @@ async def import_instance(
|
||||
)
|
||||
effective_folder_id: int | None = body.folder_id
|
||||
else:
|
||||
# Default destination: a dedicated top-level "MakerWorld" folder. Keeps
|
||||
# imports out of the library root so power users can still organise
|
||||
# manually in subfolders, and auto-creates the folder on the first
|
||||
# import so users don't have to set it up themselves.
|
||||
mw_folder_q = await db.execute(
|
||||
select(LibraryFolder).where(
|
||||
LibraryFolder.name == "MakerWorld",
|
||||
LibraryFolder.parent_id.is_(None),
|
||||
LibraryFolder.is_external.is_(False),
|
||||
# Default destination: the resolved provider's dedicated top-level
|
||||
# folder (``default_folder_name`` — read off *provider*, not the
|
||||
# MakerWorld singleton, so the second provider lands in its own
|
||||
# folder). Keeps imports out of the library root so power users can
|
||||
# still organise manually in subfolders, and auto-creates the folder
|
||||
# on the first import so users don't have to set it up themselves. A
|
||||
# provider that leaves it unset imports into the library root rather
|
||||
# than minting a NULL-named folder.
|
||||
default_folder_name = provider.default_folder_name
|
||||
if default_folder_name is None:
|
||||
effective_folder_id = None
|
||||
else:
|
||||
default_folder_q = await db.execute(
|
||||
select(LibraryFolder).where(
|
||||
LibraryFolder.name == default_folder_name,
|
||||
LibraryFolder.parent_id.is_(None),
|
||||
LibraryFolder.is_external.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
mw_folder = mw_folder_q.scalar_one_or_none()
|
||||
if mw_folder is None:
|
||||
mw_folder = LibraryFolder(name="MakerWorld", parent_id=None)
|
||||
db.add(mw_folder)
|
||||
await db.flush()
|
||||
effective_folder_id = mw_folder.id
|
||||
default_folder = default_folder_q.scalar_one_or_none()
|
||||
if default_folder is None:
|
||||
default_folder = LibraryFolder(name=default_folder_name, parent_id=None)
|
||||
db.add(default_folder)
|
||||
await db.flush()
|
||||
effective_folder_id = default_folder.id
|
||||
|
||||
# API-keyed callers carry identity on the key, not in current_user — see
|
||||
# the /status handler comment and #1777 / #1182. The same resolved user
|
||||
# is reused for owner_id on save_3mf_bytes_to_library below so the
|
||||
# library row is attributed to the key's owner rather than NULL.
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
service = await _build_service(db, cloud_token_user)
|
||||
service = await _build_service(db, provider, current_user, api_key_cloud_owner)
|
||||
|
||||
# YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
|
||||
# (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
|
||||
# Fetch design metadata to resolve it, and — in the same call — pick a
|
||||
# default profileId from the response if the frontend didn't specify one.
|
||||
# (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N} —
|
||||
# resolving that, plus picking a default profile when the frontend didn't
|
||||
# specify one, lives inside ``get_download``. The route only orchestrates
|
||||
# dedupe + persistence so every provider shares those concerns here.
|
||||
ref = ProviderResourceRef(
|
||||
source_type=provider.source_type,
|
||||
external_id=str(body.model_id),
|
||||
sub_id=str(body.profile_id) if body.profile_id else None,
|
||||
)
|
||||
|
||||
try:
|
||||
design = await service.get_design(body.model_id)
|
||||
except MakerWorldError as exc:
|
||||
await service.close()
|
||||
raise _map_service_error(exc) from exc
|
||||
info = await service.get_download(ref)
|
||||
# The provider enriches ``sub_id`` with the actually-resolved profile
|
||||
# when the caller omitted one.
|
||||
resolved_profile_id = int(info.ref.sub_id) if info.ref.sub_id else None
|
||||
|
||||
alphanumeric_model_id = design.get("modelId")
|
||||
if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
|
||||
await service.close()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="MakerWorld design metadata missing the modelId field",
|
||||
)
|
||||
# Canonical URL includes profile_id so each plate gets its own library
|
||||
# entry (see ``ModelProvider.canonical_url``).
|
||||
source_url = provider.canonical_url(info.ref)
|
||||
|
||||
profile_id = body.profile_id
|
||||
if profile_id is None:
|
||||
for instance in design.get("instances") or []:
|
||||
pid = instance.get("profileId")
|
||||
if isinstance(pid, int) and pid > 0:
|
||||
profile_id = pid
|
||||
break
|
||||
if profile_id is None:
|
||||
try:
|
||||
envelope = await service.get_design_instances(body.model_id)
|
||||
except MakerWorldError as exc:
|
||||
await service.close()
|
||||
raise _map_service_error(exc) from exc
|
||||
for hit in envelope.get("hits") or []:
|
||||
pid = hit.get("profileId")
|
||||
if isinstance(pid, int) and pid > 0:
|
||||
profile_id = pid
|
||||
break
|
||||
if profile_id is None:
|
||||
await service.close()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="MakerWorld returned no instances for this model",
|
||||
# Dedupe check upfront so we don't burn bandwidth re-downloading.
|
||||
existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
|
||||
existing_row = existing_q.scalar_one_or_none()
|
||||
if existing_row is not None:
|
||||
return MakerWorldImportResponse(
|
||||
library_file_id=existing_row.id,
|
||||
filename=existing_row.filename,
|
||||
folder_id=existing_row.folder_id,
|
||||
profile_id=resolved_profile_id,
|
||||
was_existing=True,
|
||||
)
|
||||
|
||||
# Canonical URL includes profile_id so each plate gets its own library
|
||||
# entry (see ``_canonical_url`` docstring).
|
||||
source_url = _canonical_url(body.model_id, profile_id)
|
||||
|
||||
try:
|
||||
manifest = await service.get_profile_download(profile_id, alphanumeric_model_id)
|
||||
except MakerWorldError as exc:
|
||||
await service.close()
|
||||
download = await service.download(info)
|
||||
except ProviderError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
signed_url = manifest.get("url")
|
||||
# Basename-strip any path components from the upstream filename so a
|
||||
# malicious response (``name: "../../evil.3mf"``) can't persist a suspect
|
||||
# string into the library row or the UI. On-disk storage uses a UUID
|
||||
# filename regardless (see library.py), so this is defence-in-depth.
|
||||
raw_name = manifest.get("name")
|
||||
raw_name = info.suggested_filename
|
||||
if isinstance(raw_name, str) and raw_name.strip():
|
||||
# MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
|
||||
# because the same string round-trips through HTTP URLs in the
|
||||
@@ -387,44 +421,24 @@ async def import_instance(
|
||||
suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
|
||||
else:
|
||||
suggested_name = f"makerworld-{body.model_id}.3mf"
|
||||
if not signed_url or not isinstance(signed_url, str):
|
||||
await service.close()
|
||||
raise HTTPException(status_code=502, detail="MakerWorld did not return a download URL")
|
||||
|
||||
# Dedupe check upfront so we don't burn bandwidth re-downloading.
|
||||
if source_url:
|
||||
existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
|
||||
existing_row = existing_q.scalar_one_or_none()
|
||||
if existing_row is not None:
|
||||
await service.close()
|
||||
return MakerWorldImportResponse(
|
||||
library_file_id=existing_row.id,
|
||||
filename=existing_row.filename,
|
||||
folder_id=existing_row.folder_id,
|
||||
profile_id=profile_id,
|
||||
was_existing=True,
|
||||
)
|
||||
|
||||
try:
|
||||
file_bytes, download_filename = await service.download_3mf(signed_url)
|
||||
except MakerWorldError as exc:
|
||||
await service.close()
|
||||
raise _map_service_error(exc) from exc
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
# Prefer the server-provided human-readable filename; the signed URL's
|
||||
# path ends in a UUID that's not meaningful to users. Decode the
|
||||
# fallback path-tail too — same percent-encoding round-trip applies
|
||||
# there as on the manifest-supplied name.
|
||||
filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download_filename)
|
||||
filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
|
||||
|
||||
# API-keyed callers carry identity on the key, not in current_user (#1777);
|
||||
# this collapse stays route-side solely so the library row is attributed
|
||||
# to the key's owner rather than NULL. Credential identity is resolved
|
||||
# inside the provider.
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
library_file, was_existing = await save_3mf_bytes_to_library(
|
||||
db,
|
||||
file_bytes=file_bytes,
|
||||
file_bytes=download.file_bytes,
|
||||
filename=filename,
|
||||
folder_id=effective_folder_id,
|
||||
source_type=_SOURCE_TYPE,
|
||||
source_type=provider.source_type,
|
||||
source_url=source_url,
|
||||
owner_id=cloud_token_user.id if cloud_token_user else None,
|
||||
)
|
||||
@@ -433,7 +447,7 @@ async def import_instance(
|
||||
library_file_id=library_file.id,
|
||||
filename=library_file.filename,
|
||||
folder_id=library_file.folder_id,
|
||||
profile_id=profile_id,
|
||||
profile_id=resolved_profile_id,
|
||||
was_existing=was_existing,
|
||||
)
|
||||
|
||||
@@ -442,23 +456,27 @@ async def import_instance(
|
||||
async def recent_imports(
|
||||
limit: int = 10,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
|
||||
):
|
||||
"""Last N MakerWorld imports, newest first.
|
||||
|
||||
Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
|
||||
page can show a 'Recent imports' sidebar that persists across resolves.
|
||||
Widening this to all registered providers is a behaviour change that
|
||||
belongs with the provider that needs it.
|
||||
``limit`` is clamped to ``[1, 50]`` to keep payloads sensible.
|
||||
"""
|
||||
_ = current_user # permission gate only
|
||||
capped = max(1, min(50, int(limit)))
|
||||
|
||||
result = await db.execute(
|
||||
LibraryFile.active()
|
||||
.where(LibraryFile.source_type == _SOURCE_TYPE)
|
||||
.where(LibraryFile.source_type == makerworld_provider.source_type)
|
||||
.order_by(LibraryFile.created_at.desc())
|
||||
.limit(capped)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
|
||||
return [
|
||||
MakerWorldRecentImport(
|
||||
library_file_id=row.id,
|
||||
|
||||
@@ -464,29 +464,47 @@ async def _build_authenticated_service(
|
||||
raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
|
||||
|
||||
svc = OrcaCloudService()
|
||||
svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
|
||||
if not svc.is_authenticated:
|
||||
if not svc.refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
|
||||
)
|
||||
# The service owns an httpx client from construction, and every path below
|
||||
# this point can raise. On success the caller closes it; on failure nobody
|
||||
# ever holds it, so it has to be closed here or the connection pool leaks
|
||||
# one client per failed build. That went unnoticed while the only callers
|
||||
# were routes -- a person retrying a broken sign-in a few times -- and
|
||||
# became worth fixing once spool assignment started building one too.
|
||||
try:
|
||||
svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
|
||||
if not svc.is_authenticated:
|
||||
if not svc.refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
|
||||
)
|
||||
try:
|
||||
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 — 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
|
||||
# Persist new pair BEFORE returning. A crash between here and the
|
||||
# downstream API call would still leave the user with valid stored
|
||||
# tokens for the next request.
|
||||
await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
|
||||
except BaseException:
|
||||
# BaseException, not Exception: a cancelled request leaks the client
|
||||
# just as surely as a failed refresh does. The close is guarded in turn
|
||||
# because failing to clean up must not replace the error the caller
|
||||
# needs to see -- least of all a CancelledError, which has to keep
|
||||
# propagating for cancellation to work at all.
|
||||
try:
|
||||
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 — 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
|
||||
# Persist new pair BEFORE returning. A crash between here and the
|
||||
# downstream API call would still leave the user with valid stored
|
||||
# tokens for the next request.
|
||||
await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
|
||||
await svc.close()
|
||||
except Exception as close_err: # noqa: BLE001 - cleanup is best-effort
|
||||
logger.debug("Orca Cloud client close failed while unwinding a failed build: %s", close_err)
|
||||
raise
|
||||
return svc
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from sqlalchemy import delete, func, nullslast, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import (
|
||||
RequireCameraStreamTokenIfAuthEnabled,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
require_media_token_ownership,
|
||||
require_ownership_permission,
|
||||
)
|
||||
from backend.app.core.config import settings
|
||||
@@ -136,11 +136,19 @@ async def get_print_log(
|
||||
async def get_print_log_thumbnail(
|
||||
entry_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_media_token_ownership(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Get the thumbnail for a print log entry.
|
||||
|
||||
Requires a stream token query param (?token=xxx) when auth is enabled.
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, and
|
||||
is scoped to the rows the caller can see in the log itself (#3025) -- the
|
||||
same ``created_by_id`` filter ``get_print_log`` applies, including its
|
||||
treatment of an ownerless entry as not-yours.
|
||||
|
||||
Self-heals stale entries: when thumbnail_path points to a file that no
|
||||
longer exists on disk (archive was deleted, or print failed before the
|
||||
@@ -149,9 +157,12 @@ async def get_print_log_thumbnail(
|
||||
gated on entry.thumbnail_path being truthy, so the next fetch of the
|
||||
log list will simply not request this thumbnail again.
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
entry = await db.get(PrintLogEntry, entry_id)
|
||||
if not entry or not entry.thumbnail_path:
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
if not can_read_all and (user is None or entry.created_by_id != user.id):
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
|
||||
thumb_path = settings.base_dir / entry.thumbnail_path
|
||||
if not thumb_path.exists():
|
||||
|
||||
@@ -906,29 +906,34 @@ async def add_to_queue(
|
||||
# Extract filament types for model-based assignment (used by scheduler for validation)
|
||||
required_filament_types = None
|
||||
file_path = None
|
||||
# Get file path from archive or library file
|
||||
if archive:
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
elif library_file:
|
||||
lib_path = Path(library_file.file_path)
|
||||
file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
|
||||
if target_model_norm:
|
||||
# Get file path from archive or library file
|
||||
if archive:
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
elif library_file:
|
||||
lib_path = Path(library_file.file_path)
|
||||
file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
|
||||
|
||||
if file_path and file_path.exists():
|
||||
filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
|
||||
if filament_types:
|
||||
required_filament_types = json.dumps(filament_types)
|
||||
logger.info("Extracted filament types for model-based queue: %s", filament_types)
|
||||
|
||||
# If filament overrides are provided, update required_filament_types to match override types
|
||||
# If filament overrides are provided, update required_filament_types to match override types.
|
||||
# A specific-printer job keeps its overrides too (#3133): an override chosen for
|
||||
# "Any P2S" survives the switch to one P2S in the print dialog, and when the
|
||||
# dialog could not resolve every tray the scheduler recomputes the mapping at
|
||||
# dispatch — against the 3MF's filament, unless the row still says otherwise.
|
||||
# The type list below stays model-only; it gates which printer of a model is
|
||||
# eligible, which a printer-targeted job has already settled.
|
||||
filament_overrides_json = None
|
||||
if data.filament_overrides and target_model_norm:
|
||||
if data.filament_overrides and (target_model_norm or data.printer_id is not None):
|
||||
plate_overrides = overrides_for_plate(data.filament_overrides, file_path, data.plate_id)
|
||||
if plate_overrides:
|
||||
filament_overrides_json = json.dumps(plate_overrides)
|
||||
# Update required_filament_types from overrides so scheduler validates against overridden types
|
||||
override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
|
||||
if override_types:
|
||||
if override_types and target_model_norm:
|
||||
# Merge with existing types (overrides may only cover some slots)
|
||||
existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
|
||||
# Replace types for overridden slots, keep others
|
||||
@@ -971,6 +976,15 @@ async def add_to_queue(
|
||||
batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename
|
||||
else:
|
||||
batch_name_base = library_file.filename
|
||||
elif variant_specs:
|
||||
# A cross-model job carries neither archive_id nor library_file_id --
|
||||
# the candidates are the files (#671) -- so both branches above miss
|
||||
# and every such batch was named "Batch". Unreachable until the print
|
||||
# dialog could ask for more than one copy of one (#3101). Name it
|
||||
# after the first candidate, which is what the dialog names the job
|
||||
# after and what the resolver prefers when both printers are free.
|
||||
first_file = variant_specs[0][1]
|
||||
batch_name_base = (first_file.file_metadata or {}).get("print_name") or first_file.filename or "Batch"
|
||||
batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "")
|
||||
|
||||
batch = PrintBatch(
|
||||
|
||||
@@ -13,11 +13,12 @@ from starlette.background import BackgroundTask
|
||||
|
||||
from backend.app.core import database
|
||||
from backend.app.core.auth import (
|
||||
RequireCameraStreamTokenIfAuthEnabled,
|
||||
RequireOverlayTokenIfAuthEnabled,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
RequirePrinterPermissionIfAuthEnabled,
|
||||
is_auth_enabled,
|
||||
require_media_token_permission,
|
||||
require_media_token_printer_permission,
|
||||
)
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
@@ -87,9 +88,11 @@ from backend.app.services.printer_media import (
|
||||
remove_printer_files_zip,
|
||||
start_printer_files_job,
|
||||
)
|
||||
from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID
|
||||
from backend.app.services.slot_nozzle import resolve_slot_nozzle
|
||||
from backend.app.utils.ams_humidity import ams_humidity_percent
|
||||
from backend.app.utils.filament_ids import filament_id_to_setting_id
|
||||
from backend.app.utils.filament_types import printer_filament_type
|
||||
from backend.app.utils.filament_types import is_material_name, printer_filament_type
|
||||
from backend.app.utils.fts_routing import slot_extruder
|
||||
from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
|
||||
from backend.app.utils.kprofile_lookup import build_slot_k_resolver
|
||||
@@ -526,13 +529,14 @@ async def get_printer_status(
|
||||
ams_exists = False
|
||||
raw_data = state.raw_data or {}
|
||||
|
||||
# K value for a slot's bound profile, resolved against its own nozzle.
|
||||
# K value for a slot's bound profile, preferring the slot's own nozzle.
|
||||
#
|
||||
# Keyed on more than cali_idx: the printer numbers its calibration table
|
||||
# per nozzle, so entry 16 exists on each and means a different profile on
|
||||
# each. A cali_idx-only map let whichever profile the printer happened to
|
||||
# list last overwrite the other, and the slot then displayed the wrong
|
||||
# nozzle's K — on the maintainer's H2C, 0.018 and 0.020 for the same spool.
|
||||
# cali_idx alone is not enough: two profiles can share an index and differ
|
||||
# by extruder, and a cali_idx-only map let whichever the printer listed
|
||||
# last overwrite the other — on the maintainer's H2C, 0.018 and 0.020 for
|
||||
# the same spool. Nor is the extruder a requirement: one profile can be
|
||||
# what both extruders' slots point at, and demanding a match blanked every
|
||||
# slot on a second AMS (#3044). The resolver does both in order.
|
||||
_kprofile_k = build_slot_k_resolver(state)
|
||||
|
||||
# Cached active-cycle drying params (filament + target temp) we sent
|
||||
@@ -583,22 +587,11 @@ async def get_printer_status(
|
||||
exists=tray_data.get("exists"),
|
||||
)
|
||||
)
|
||||
# Prefer humidity_raw (percentage) over humidity (index 1-5)
|
||||
# humidity_raw is the actual percentage value from the sensor
|
||||
humidity_raw = ams_data.get("humidity_raw")
|
||||
humidity_idx = ams_data.get("humidity")
|
||||
humidity_value = None
|
||||
|
||||
if humidity_raw is not None:
|
||||
try:
|
||||
humidity_value = int(humidity_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip unparseable humidity; will try index fallback
|
||||
if humidity_value is None and humidity_idx is not None:
|
||||
try:
|
||||
humidity_value = int(humidity_idx)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip unparseable humidity index; humidity remains None
|
||||
# Percentage only. The 1-5 ``humidity`` index is never substituted
|
||||
# for one -- it is inverted, so it would read as the opposite of
|
||||
# what it means (#3140). See utils/ams_humidity.
|
||||
humidity_pct = ams_humidity_percent(ams_data)
|
||||
humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
|
||||
# AMS-HT has 1 tray, regular AMS has 4 trays
|
||||
is_ams_ht = len(trays) == 1
|
||||
|
||||
@@ -1146,10 +1139,16 @@ async def _running_print_archive_file(printer_id: int, state) -> Path | None:
|
||||
async def get_printer_cover(
|
||||
printer_id: int,
|
||||
view: str | None = None,
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
_: User | None = Depends(require_media_token_permission(Permission.PRINTERS_READ)),
|
||||
):
|
||||
"""Get the cover image for the current print job.
|
||||
|
||||
Requires a media token query param (?token=xxx) when auth is enabled, plus
|
||||
``printers:read`` -- the permission that governs every other read of this
|
||||
printer. It used to require ``camera:view`` by way of the camera-stream
|
||||
token, which is a different question from "may this user see what is on the
|
||||
plate" (#3025).
|
||||
|
||||
Args:
|
||||
view: Optional view type. Use "top" for the top-down build plate view or
|
||||
"pick" for the slicer's object-ID mask used by skip objects.
|
||||
@@ -1970,7 +1969,7 @@ async def get_printer_file_plate_thumbnail(
|
||||
printer_id: int,
|
||||
plate_index: int,
|
||||
path: str = Query(..., description="Full path to the 3MF file on the printer"),
|
||||
_=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
||||
_=Depends(require_media_token_printer_permission(Permission.PRINTERS_FILES)),
|
||||
):
|
||||
"""Get a plate thumbnail image from a printer-stored 3MF file."""
|
||||
import io
|
||||
@@ -2849,10 +2848,49 @@ async def configure_ams_slot(
|
||||
if not client:
|
||||
raise HTTPException(status_code=400, detail="Printer not connected")
|
||||
|
||||
# Discard a tray_info_idx the printer cannot store (#3003).
|
||||
#
|
||||
# The field is 8 characters wide. A local preset id ("P" + 7 hex) is
|
||||
# exactly 8, which is presumably why nobody noticed -- but a cloud
|
||||
# *setting* id is 18, and the firmware keeps the first 8 and reports
|
||||
# success. Measured on @marivo's A1 in the #3003 bundle:
|
||||
#
|
||||
# sent tray_info_idx=PFUS9ddc938fe3ab8f
|
||||
# printer Assignment NOT confirmed: tray shows PFUS9DDC
|
||||
#
|
||||
# `PFUS9ddc` resolves to nothing anywhere, so the slot came out of the
|
||||
# Configure modal as "Generic <material>" in the slicer -- strictly worse
|
||||
# than the base filament it would have got from the fallback below, and it
|
||||
# also breaks the calibration table, which is keyed by this field.
|
||||
#
|
||||
# Blanking it here is what hands the slot to the reuse / generic branch.
|
||||
# The preset reference is not lost: it stays in setting_id, the field that
|
||||
# does accept a PFUS. Same four rejected shapes, and the same reasoning, as
|
||||
# `slicer_filament_resolver`'s closing guard -- which the assignment path
|
||||
# has run since #1815 while Configure had none. The Orca profile UUID is on
|
||||
# the list for the same reason as the rest: the modal no longer sends one,
|
||||
# but this route is public API and 36 characters is the worst of the four
|
||||
# against an 8-character field.
|
||||
if tray_info_idx and (
|
||||
tray_info_idx.startswith("PFUS")
|
||||
or tray_info_idx.startswith("PFCN")
|
||||
or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
|
||||
or is_material_name(tray_info_idx)
|
||||
):
|
||||
logger.info(
|
||||
"[configure_ams_slot] tray_info_idx %r is not storable as a filament id — "
|
||||
"falling back to slot reuse / generic (kept as setting_id %r)",
|
||||
tray_info_idx,
|
||||
setting_id or tray_info_idx,
|
||||
)
|
||||
if not setting_id and (tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")):
|
||||
setting_id = tray_info_idx
|
||||
tray_info_idx = ""
|
||||
|
||||
# Resolve tray_info_idx for the MQTT command.
|
||||
# Priority:
|
||||
# 1. Use the provided tray_info_idx if set (including cloud-synced
|
||||
# custom presets like PFUS* / P*).
|
||||
# 1. Use the provided tray_info_idx if set, once the guard above has had
|
||||
# its say (so: a GF* official or P* local id, never a PFUS/PFCN one).
|
||||
# 2. Reuse the slot's existing tray_info_idx if it's a specific
|
||||
# (non-generic) preset for the same material.
|
||||
# 3. Fall back to a generic Bambu filament ID.
|
||||
@@ -3804,10 +3842,12 @@ async def bed_jog(
|
||||
distance: float = Query(
|
||||
...,
|
||||
description=(
|
||||
"Signed nozzle-bed gap adjustment in mm. Negative = decrease gap "
|
||||
'("up" arrow in the UI: bed up on bed-on-Z models, toolhead down '
|
||||
"on A1 bed-slingers). Positive = increase gap. The backend "
|
||||
"translates this into the right G-code Z sign per printer model."
|
||||
"Signed nozzle-bed gap adjustment in mm, identical on every model: "
|
||||
"positive opens the gap (more clearance), negative closes it. Sent "
|
||||
"to the printer as the G-code Z value unchanged — G-code Z is the "
|
||||
"nozzle-to-bed distance whether the bed moves (X1 / P1 / H2) or the "
|
||||
"toolhead does (A1 / A2L), so no per-model sign translation exists "
|
||||
"or is needed."
|
||||
),
|
||||
),
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
@@ -3817,31 +3857,49 @@ async def bed_jog(
|
||||
|
||||
Emits a short G-code sequence via MQTT.
|
||||
|
||||
Soft-endstop policy (#2579). The printer's software travel limits are the
|
||||
only thing between a jog button and a bed crash — on Bambu machines the
|
||||
physical endstops are homing-only (there is no runtime limit switch in the
|
||||
travel path), so once they are disabled nothing stops the move. The old
|
||||
code disabled them (``M211 S0``) around every forced jog, and the UI sent
|
||||
``force`` on every jog, so the limits were off on every bed move — that is
|
||||
what let a jog drive the nozzle into the bed on all models (#2579). This
|
||||
endpoint now emits a **bare relative move and never touches ``M211`` at
|
||||
all** — byte-for-byte what the printer's own touchscreen jog sends, which
|
||||
stops at the travel limit. Bambuddy no longer disables the firmware's soft
|
||||
endstops, and it no longer sends ``M211 S1`` either: that was an unverified
|
||||
attempt to re-enable a printer left disabled by an older build, and on real
|
||||
hardware the jog moved past the limit *with* it. If a printer still jogs
|
||||
past its limits, its endstops were disabled at the firmware level by the old
|
||||
build — power-cycle it once to restore them; from then on Bambuddy leaves
|
||||
them alone.
|
||||
Soft-endstop policy (#2579). **Nothing clamps this move.** Bambu's firmware
|
||||
does not enforce its soft endstops on G-code arriving over MQTT — measured
|
||||
by logging the exact bytes to an H2D sitting at its Z limit: a clean
|
||||
``G91 / G1 Z-1.00 F600 / G90`` with no ``M211`` ran straight past, while the
|
||||
printer's own touchscreen refuses the identical move, because the
|
||||
touchscreen goes through the motion planner and ``gcode_line`` does not.
|
||||
Push-status carries no axis position either, so there is nothing to clamp
|
||||
against on this side. Treat every jog as unguarded; the jog popover says so
|
||||
to the user, and a dead-reckoning clamp (track Z from a home, refuse
|
||||
out-of-range moves) is the only real fix and is not built.
|
||||
|
||||
Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
|
||||
is the Z-axis, and Bambu's home convention puts Z=0 at the top with
|
||||
Z+ moving the bed down — so a frontend "Up" (decrease gap) maps
|
||||
naturally to ``G1 Z-``. On bed-slingers (A1 / A1 Mini) the Z-axis is
|
||||
the *toolhead*, and ``G1 Z-`` instead drives the nozzle DOWN into the
|
||||
bed (#1334 reported exactly that crash). For those models we invert
|
||||
the sign before emitting the G-code, so the UI semantics stay the
|
||||
same regardless of which part physically moves.
|
||||
What Bambuddy stopped doing is making it worse. The old code wrapped every
|
||||
move in ``M211 S0`` / ``M211 S1`` and the UI sent ``force`` on every jog, so
|
||||
the limits came off on every bed move — and ``M211 S0`` disables them
|
||||
*globally*, which broke the touchscreen's protection too until the printer
|
||||
was power-cycled. That is the one genuine Bambuddy bug in #2579. This
|
||||
endpoint now emits a bare relative move and never touches ``M211`` at all,
|
||||
which leaves the touchscreen protected. It does not send ``M211 S1``
|
||||
either: that was an unverified attempt to re-enable a printer an older
|
||||
build had disabled, and on real hardware the jog moved past the limit
|
||||
*with* it. A printer left in that state is recovered with one power cycle.
|
||||
|
||||
Direction (#1334, and the API half of it reported by @AQU4R1U5). ``Z``
|
||||
is the nozzle-to-bed gap on every Bambu model, by definition of the
|
||||
coordinate system rather than by convention: ``G1 Z+`` opens the gap
|
||||
whether the bed drops away (X1 / P1 / H2, where Bambu's end G-code
|
||||
parks with ``G1 Z{max_layer_z + 100}``) or the toolhead rises
|
||||
(A1 / A1 Mini / A2L). The finish-photo plate restore relies on exactly
|
||||
that and needs no model branch — see ``_restore_plate_for_finish_photo``.
|
||||
|
||||
So ``distance`` goes onto the wire unchanged, and one API call means one
|
||||
physical thing on every printer: positive is always the safe direction.
|
||||
This endpoint used to invert the sign on A1 models, which made a
|
||||
documented model-independent parameter mean the opposite thing there —
|
||||
``distance=5``, asking for clearance, drove the toolhead at the plate.
|
||||
|
||||
What #1334 actually reported is a *label* problem, and it belongs to the
|
||||
UI: the arrow says "move the plate up", and on a bed-slinger the plate
|
||||
does not move in Z at all, so closing the gap shows up as the toolhead
|
||||
diving. Which way an arrow points is a question about the machine in
|
||||
front of the user, not about the G-code, so the printer card decides it
|
||||
(``isBedSlinger`` in ``frontend/src/utils/bedSlinger.ts``) and sends the
|
||||
gap it wants. Nothing here needs to know the model.
|
||||
"""
|
||||
if distance == 0 or abs(distance) > 200:
|
||||
raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
|
||||
@@ -3855,14 +3913,10 @@ async def bed_jog(
|
||||
if not client:
|
||||
raise HTTPException(400, "Printer not connected")
|
||||
|
||||
from backend.app.services.printer_manager import is_bed_slinger
|
||||
|
||||
gcode_distance = -distance if is_bed_slinger(printer.model) else distance
|
||||
|
||||
# Bare relative move — exactly what the touchscreen sends. Never touch M211
|
||||
# (#2579): the firmware keeps its soft endstops on by default and clamps the
|
||||
# move at the travel limit.
|
||||
lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
|
||||
# Bare relative move, never M211 (#2579). Not because a bare move is safe —
|
||||
# the firmware ignores soft endstops on MQTT G-code either way — but because
|
||||
# M211 S0 disabled them globally, taking the touchscreen's limits with it.
|
||||
lines = ["G91", f"G1 Z{distance:.2f} F600", "G90"]
|
||||
|
||||
if not client.send_gcode("\n".join(lines)):
|
||||
raise HTTPException(500, "Failed to send bed-jog command")
|
||||
@@ -3897,9 +3951,9 @@ async def xy_jog(
|
||||
if y:
|
||||
axes.append(f"Y{y:.2f}")
|
||||
|
||||
# Bare relative move — never touch M211 (#2579). The firmware keeps its soft
|
||||
# endstops on by default and clamps the move at the travel limit; a printer
|
||||
# left disabled by an older build is recovered with a power cycle.
|
||||
# Bare relative move, never M211 (#2579) — see the bed-jog docstring. The
|
||||
# firmware does not enforce soft endstops on MQTT G-code, so this move is
|
||||
# unguarded; M211 S0 only widened that to the touchscreen as well.
|
||||
if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
|
||||
raise HTTPException(500, "Failed to send XY jog command")
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.api.routes.library import get_library_dir
|
||||
from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
@@ -1412,13 +1412,19 @@ async def upload_project_cover_image(
|
||||
async def get_project_cover_image(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
_: User | None = Depends(require_media_token_permission(Permission.PROJECTS_READ)),
|
||||
):
|
||||
"""Stream the project's cover image (#1155).
|
||||
|
||||
Browsers can't attach `Authorization: Bearer ...` to `<img src>` requests,
|
||||
so this route accepts the same `?token=` stream-credential as
|
||||
/archives/{id}/thumbnail. The frontend wraps URLs with `withStreamToken`."""
|
||||
so this route accepts a `?token=` media credential, the same one
|
||||
/archives/{id}/thumbnail takes. The frontend wraps URLs with `withMediaToken`.
|
||||
|
||||
Gated on ``projects:read`` like every other project route. It used to take
|
||||
the camera-stream token, which required ``camera:view`` instead -- an
|
||||
unrelated permission that a user could hold without any project access, and
|
||||
that a project reader could easily lack (#3025). Projects carry no
|
||||
``created_by_id``, so there is no per-row owner to check beyond that."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
|
||||
@@ -11,8 +11,13 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.auth import (
|
||||
RequirePermissionIfAuthEnabled,
|
||||
caller_is_api_key,
|
||||
require_auth_if_enabled,
|
||||
require_energy_cost_update,
|
||||
)
|
||||
from backend.app.core.config import APP_VERSION, settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.settings import Settings
|
||||
@@ -490,6 +495,57 @@ async def get_ui_preferences(db: AsyncSession = Depends(get_db)):
|
||||
return {key: dumped[key] for key in _UI_PREFERENCE_FIELDS if key in dumped}
|
||||
|
||||
|
||||
# Install configuration the app shell reads before it can render correctly.
|
||||
#
|
||||
# Deliberately a second list rather than more entries in _UI_PREFERENCE_FIELDS.
|
||||
# That one is served to anyone at all, on the recorded grounds that its contents
|
||||
# are "public defaults that ship with the app" (test_route_auth_coverage.py), and
|
||||
# its field set is pinned by a test written to make anyone adding to it stop and
|
||||
# think. These fields are not defaults -- they are facts about how this
|
||||
# particular deployment is configured -- so they get their own endpoint at their
|
||||
# own trust level instead of stretching that charter to fit them.
|
||||
_UI_FLAG_FIELDS: tuple[str, ...] = (
|
||||
# The sidebar hides Finance unless billing is on. Layout read this from
|
||||
# GET /settings, which requires SETTINGS_READ, so for a non-admin the query
|
||||
# 403'd, the value arrived undefined, `undefined !== true` held, and the
|
||||
# entry was hidden from exactly the users cost_centers:read_own exists to
|
||||
# serve. The page itself was reachable by URL the whole time (#3023).
|
||||
"billing_enabled",
|
||||
# Same 403, opposite outcome. That gate tests `=== false`, which undefined
|
||||
# never satisfies, so an administrator who turned user notifications off
|
||||
# still left the entry showing -- to precisely the non-admins it governs.
|
||||
"user_notifications_enabled",
|
||||
# Not gates, but read by the shell and equally undefined for a non-admin:
|
||||
# the sponsor prompt fell back to EUR whatever the install uses, and the
|
||||
# update check ran even where it had been switched off.
|
||||
"currency",
|
||||
"check_updates",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ui-flags")
|
||||
async def get_ui_flags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_auth_if_enabled),
|
||||
):
|
||||
"""Install configuration the app shell needs, for any signed-in user.
|
||||
|
||||
Gated on being authenticated rather than on ``SETTINGS_READ``. The sidebar
|
||||
has to know whether billing is enabled before it can decide whether to offer
|
||||
Finance, and ``SETTINGS_READ`` cannot be the price of knowing that -- it also
|
||||
grants sight of the SMTP, LDAP and MQTT credentials.
|
||||
|
||||
``require_auth_if_enabled`` returns ``None`` when auth is switched off
|
||||
entirely, which is the case /ui-preferences was left ungated for. That is the
|
||||
distinction the two endpoints draw: "works when there is no auth" is not the
|
||||
same statement as "readable by anyone", and conflating them is what put a
|
||||
settings read in front of a permission that was never meant to require one.
|
||||
"""
|
||||
full = await _build_settings_response(db, is_api_key=False)
|
||||
dumped = full.model_dump()
|
||||
return {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
|
||||
|
||||
|
||||
@router.get("/check-ffmpeg")
|
||||
async def check_ffmpeg(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
@@ -736,6 +792,20 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
|
||||
except PermissionError as e:
|
||||
logger.warning("Permission denied copying %s: %s", name, e)
|
||||
|
||||
# Say which version made this, so a restore that cannot import it can
|
||||
# name the versions rather than a list of columns. Backups from before
|
||||
# this existed simply have no manifest, and restore treats the version
|
||||
# as unknown.
|
||||
import json as _json
|
||||
|
||||
manifest = {
|
||||
"format": 1,
|
||||
"app_version": APP_VERSION,
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"database": "sqlite" if is_sqlite() else "postgresql",
|
||||
}
|
||||
(temp_path / "manifest.json").write_text(_json.dumps(manifest, indent=2) + "\n")
|
||||
|
||||
# Include the MFA encryption key as a ZIP top-level entry alongside
|
||||
# bambuddy.db. Without it, encrypted client_secret / TOTP secret rows
|
||||
# would be unrecoverable after restore on a host without MFA_ENCRYPTION_KEY set.
|
||||
@@ -797,6 +867,119 @@ async def create_backup(
|
||||
)
|
||||
|
||||
|
||||
class BackupSchemaIncompatible(Exception):
|
||||
"""The backup has no value for a column this version requires.
|
||||
|
||||
A backup carries the schema of the install that made it. Restoring it into
|
||||
a different version means the destination can have NOT NULL columns the
|
||||
backup never heard of -- either because that version is older and still has
|
||||
a column since removed (``user_wallets.currency``, dropped in #3123), or
|
||||
because it is newer and has added one. Most such columns have a default and
|
||||
can simply be filled. The ones that cannot are what this reports, and it has
|
||||
to be reported BEFORE the restore drops anything: the Postgres import wipes
|
||||
every table in the first transaction, so a failure halfway leaves the
|
||||
install with an empty schema and the previous data gone.
|
||||
"""
|
||||
|
||||
|
||||
def _missing_required_columns(pg_table, src_columns: set[str]):
|
||||
"""Split the destination's NOT NULL columns that the backup lacks.
|
||||
|
||||
Returns ``(injectable, db_filled, unfillable)``:
|
||||
|
||||
* ``injectable`` -- ``{name: value}`` from the model's Python-side default.
|
||||
These are invisible to the import's raw SQL: SQLAlchemy applies a
|
||||
``default=`` on ORM and Core inserts, never on ``text()``, and
|
||||
``create_all`` emits no DDL default for one. So a column like
|
||||
``currency VARCHAR(3) NOT NULL`` with ``default="EUR"`` arrives with
|
||||
nothing to put in it unless we put it there.
|
||||
* ``db_filled`` -- has a server default or is the autoincrement key; the
|
||||
database fills it when the column is left out of the INSERT.
|
||||
* ``unfillable`` -- nothing can supply a value. The backup is incompatible.
|
||||
"""
|
||||
injectable: dict = {}
|
||||
db_filled: list[str] = []
|
||||
unfillable: list[str] = []
|
||||
|
||||
for col in pg_table.columns:
|
||||
if col.nullable or col.name in src_columns:
|
||||
continue
|
||||
if col.default is not None:
|
||||
arg = col.default.arg
|
||||
injectable[col.name] = arg(None) if callable(arg) else arg
|
||||
elif col.server_default is not None or col.primary_key:
|
||||
db_filled.append(col.name)
|
||||
else:
|
||||
unfillable.append(col.name)
|
||||
|
||||
return injectable, db_filled, unfillable
|
||||
|
||||
|
||||
def check_backup_schema_compatible(sqlite_path: Path, backup_version: str | None = None) -> None:
|
||||
"""Raise if this version cannot import that backup. Touches nothing.
|
||||
|
||||
Only the cross-engine path needs this. A SQLite install restores by copying
|
||||
the backup's pages, schema included, and `init_db()` migrates it forward
|
||||
afterwards; the Postgres import instead recreates the schema from THIS
|
||||
process's ORM and then inserts the backup's columns into it.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
src = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
||||
try:
|
||||
src_tables = {
|
||||
row[0]
|
||||
for row in src.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'"
|
||||
)
|
||||
}
|
||||
problems: list[str] = []
|
||||
# metadata.tables, not sorted_tables: the latter warns about the
|
||||
# library_files/library_folders/print_archives cycle, and nothing here
|
||||
# depends on the order.
|
||||
for name, pg_table in Base.metadata.tables.items():
|
||||
if name not in src_tables:
|
||||
continue
|
||||
# An empty table inserts nothing, so a column it cannot supply
|
||||
# cannot fail. Refusing a restore over one would be a false alarm.
|
||||
if src.execute(f'SELECT 1 FROM "{name}" LIMIT 1').fetchone() is None: # noqa: S608 # nosec B608 — name comes from ORM metadata
|
||||
continue
|
||||
src_columns = {row[1] for row in src.execute(f'PRAGMA table_info("{name}")')}
|
||||
_, _, unfillable = _missing_required_columns(pg_table, src_columns)
|
||||
problems.extend(f"{name}.{col}" for col in unfillable)
|
||||
finally:
|
||||
src.close()
|
||||
|
||||
if not problems:
|
||||
return
|
||||
|
||||
made_by = f"The backup was made by Bambuddy {backup_version}, " if backup_version else "The backup "
|
||||
raise BackupSchemaIncompatible(
|
||||
"This backup cannot be restored by this version of Bambuddy. It carries no value for "
|
||||
f"{len(problems)} column(s) this version requires and cannot default: {', '.join(sorted(problems))}. "
|
||||
f"{made_by}and this install runs {APP_VERSION}. Restore it on the version that made it, or "
|
||||
"upgrade this install to that version. Nothing has been changed."
|
||||
)
|
||||
|
||||
|
||||
def _read_backup_manifest(temp_path: Path) -> dict:
|
||||
"""The backup's manifest.json, or {} for a backup made before it existed."""
|
||||
import json
|
||||
|
||||
path = temp_path / "manifest.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("Ignoring unreadable backup manifest: %s", exc)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
|
||||
"""Import data from a SQLite database file into the current PostgreSQL database.
|
||||
|
||||
@@ -809,6 +992,11 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
|
||||
|
||||
from backend.app.core.database import Base, _create_engine
|
||||
|
||||
# Before anything is dropped. The route checks this too, earlier and with
|
||||
# the backup's version in the message; this call is what makes the guarantee
|
||||
# a property of the import itself rather than of one caller.
|
||||
check_backup_schema_compatible(sqlite_path)
|
||||
|
||||
# Create a temporary engine for the import (current engine was disposed)
|
||||
pg_engine = _create_engine()
|
||||
|
||||
@@ -917,8 +1105,24 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
|
||||
if not columns:
|
||||
continue
|
||||
|
||||
col_list = ", ".join(columns)
|
||||
param_list = ", ".join(f":{c}" for c in columns)
|
||||
# Columns this schema requires that the backup does not have at
|
||||
# all. The block below handles a column PRESENT in the backup
|
||||
# with a NULL in it; one the backup never had is not in
|
||||
# `columns` and so never reached it -- which is how a backup
|
||||
# from an install without `user_wallets.currency` died on
|
||||
# NotNullViolationError against a version that still had it.
|
||||
injected, _db_filled, _unfillable = _missing_required_columns(pg_table, set(src_columns))
|
||||
if injected:
|
||||
logger.info(
|
||||
"Filling %s column(s) absent from the backup in %s: %s",
|
||||
len(injected),
|
||||
table_name,
|
||||
", ".join(sorted(injected)),
|
||||
)
|
||||
|
||||
insert_columns = columns + list(injected)
|
||||
col_list = ", ".join(insert_columns)
|
||||
param_list = ", ".join(f":{c}" for c in insert_columns)
|
||||
# ON CONFLICT DO NOTHING handles duplicate rows from SQLite (which doesn't enforce unique constraints)
|
||||
insert_sql = text(f"INSERT INTO {table_name} ({col_list}) VALUES ({param_list}) ON CONFLICT DO NOTHING") # noqa: S608 # nosec B608
|
||||
|
||||
@@ -959,9 +1163,15 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
|
||||
now = dt.now()
|
||||
|
||||
def _convert_row(
|
||||
row, cols=columns, bools=bool_columns, dts=datetime_columns, nn_defaults=not_null_defaults, _now=now
|
||||
row,
|
||||
cols=columns,
|
||||
bools=bool_columns,
|
||||
dts=datetime_columns,
|
||||
nn_defaults=not_null_defaults,
|
||||
_now=now,
|
||||
inject=injected,
|
||||
):
|
||||
result = {}
|
||||
result = dict(inject)
|
||||
for c in cols:
|
||||
val = row[c]
|
||||
if val is None and c in nn_defaults:
|
||||
@@ -1093,6 +1303,30 @@ async def restore_backup(
|
||||
if not backup_db.exists():
|
||||
raise HTTPException(400, "Invalid backup: missing bambuddy.db")
|
||||
|
||||
# 2b. Can this version import this backup at all?
|
||||
#
|
||||
# Deliberately here: everything below has a side effect. The virtual
|
||||
# printer stops, background services stop, the MFA key file is
|
||||
# overwritten with the backup's -- and then the Postgres import drops
|
||||
# every table in its first transaction. A backup rejected at the INSERT
|
||||
# took the install's data with it and left the encrypted secrets under a
|
||||
# key that no longer matches. Nothing above this line has touched
|
||||
# anything.
|
||||
import sqlite3
|
||||
|
||||
manifest = _read_backup_manifest(temp_path)
|
||||
backup_version = manifest.get("app_version")
|
||||
if backup_version:
|
||||
logger.info("Backup was created by Bambuddy %s; this install runs %s", backup_version, APP_VERSION)
|
||||
if not is_sqlite():
|
||||
try:
|
||||
check_backup_schema_compatible(backup_db, backup_version)
|
||||
except BackupSchemaIncompatible as exc:
|
||||
logger.error("Refusing backup: %s", exc)
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except sqlite3.DatabaseError as exc:
|
||||
raise HTTPException(400, f"Invalid backup: bambuddy.db is not readable ({exc})") from exc
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
|
||||
from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
|
||||
from backend.app.api.routes.orca_cloud import (
|
||||
_ORCA_TYPE_TO_BAMBU,
|
||||
_build_authenticated_service as _build_orca_service,
|
||||
@@ -43,6 +43,7 @@ from backend.app.services.bambu_cloud import (
|
||||
BambuCloudError,
|
||||
BambuCloudService,
|
||||
)
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
from backend.app.services.orca_cloud import (
|
||||
OrcaCloudAuthError,
|
||||
OrcaCloudError,
|
||||
|
||||
@@ -421,6 +421,11 @@ async def nfc_tag_scanned(
|
||||
"material": mapped["material"],
|
||||
"subtype": mapped["subtype"],
|
||||
"color_name": mapped["color_name"],
|
||||
# Spoolman stores no colour name, so `color_name`
|
||||
# here is usually the spool's subtype standing in
|
||||
# for one. The kiosk needs to know that to prefer
|
||||
# the colour catalog over "Silk+" (#3090).
|
||||
"color_name_is_synthesized": mapped["color_name_is_synthesized"],
|
||||
"rgba": mapped["rgba"],
|
||||
"brand": mapped["brand"],
|
||||
"label_weight": mapped["label_weight"],
|
||||
@@ -485,6 +490,9 @@ async def nfc_tag_scanned(
|
||||
"material": spool.material,
|
||||
"subtype": spool.subtype,
|
||||
"color_name": spool.color_name,
|
||||
# Local inventory stores what the user or their tag
|
||||
# set, and nothing else — never a stand-in (#3090).
|
||||
"color_name_is_synthesized": False,
|
||||
"rgba": spool.rgba,
|
||||
"brand": spool.brand,
|
||||
"label_weight": spool.label_weight,
|
||||
|
||||
@@ -62,6 +62,7 @@ from backend.app.services.spoolman import (
|
||||
init_spoolman_client,
|
||||
)
|
||||
from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
|
||||
from backend.app.services.tag_conflict import tag_already_linked
|
||||
from backend.app.utils.color_utils import spoolman_color_hex
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
@@ -1154,6 +1155,18 @@ async def sync_spool_weight(
|
||||
return {"status": "ok", "weight_used": weight_used}
|
||||
|
||||
|
||||
def _extra_tag(spool: dict) -> str:
|
||||
"""The tag stored in a Spoolman spool's ``extra``, normalised for comparison.
|
||||
|
||||
Anything that is not a string reads as no tag. ``extra`` is free-form and
|
||||
edited outside Bambuddy, and ``.get("tag", "")`` does not default a key
|
||||
that is present and null -- that returns None, which has no ``.strip``.
|
||||
"""
|
||||
extra = spool.get("extra")
|
||||
raw = extra.get("tag") if isinstance(extra, dict) else None
|
||||
return raw.strip('"').upper() if isinstance(raw, str) else ""
|
||||
|
||||
|
||||
@router.patch("/spools/{spool_id}/tag")
|
||||
async def link_tag_to_spoolman_spool(
|
||||
*,
|
||||
@@ -1165,8 +1178,10 @@ async def link_tag_to_spoolman_spool(
|
||||
"""Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
|
||||
|
||||
tray_uuid takes precedence over tag_uid when both are supplied.
|
||||
Returns 409 if another spool already carries the same tag.
|
||||
Uses extra_lock to serialise against concurrent extra-field writes.
|
||||
|
||||
A tag another active spool already carries is refused with the shared
|
||||
``tag_already_linked`` 409, identical to the built-in route's (#3110).
|
||||
"""
|
||||
client = await _get_client(db)
|
||||
tag = (data.tray_uuid or data.tag_uid).upper()
|
||||
@@ -1174,15 +1189,25 @@ async def link_tag_to_spoolman_spool(
|
||||
|
||||
async with client.extra_lock(spool_id):
|
||||
# Duplicate check: scan all spools for the same tag on a different spool.
|
||||
# Sorted, because Spoolman has no unique constraint on extra.tag either,
|
||||
# and a caller offered whichever row the scan happened to reach first
|
||||
# could not tell two holders apart. The built-in route names the lowest
|
||||
# id for the same reason (#3110).
|
||||
#
|
||||
# Sorting means every row is read, where the old loop stopped at its
|
||||
# first match, so one malformed row after the holder must not be able
|
||||
# to take the whole request down: _extra_tag refuses a non-string, and
|
||||
# a row without an integer id cannot be named and so is not treated as
|
||||
# a holder. Bambuddy only ever writes a JSON string here; a third party
|
||||
# editing extra.tag in Spoolman is what puts anything else in reach.
|
||||
async with _translate_spoolman_errors():
|
||||
all_spools = await client.get_all_spools()
|
||||
for s in all_spools:
|
||||
s_tag = (s.get("extra") or {}).get("tag", "")
|
||||
if s_tag.strip('"').upper() == tag and s.get("id") != spool_id:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Tag is already assigned to spool {s['id']}",
|
||||
)
|
||||
holders = sorted(
|
||||
(s for s in all_spools if _extra_tag(s) == tag and isinstance(s.get("id"), int) and s["id"] != spool_id),
|
||||
key=lambda s: s["id"],
|
||||
)
|
||||
if holders:
|
||||
raise tag_already_linked("tray_uuid" if data.tray_uuid else "tag_uid", holders[0]["id"])
|
||||
|
||||
# Re-fetch inside the lock so cur_extra reflects any concurrent update.
|
||||
async with _translate_spoolman_errors():
|
||||
|
||||
@@ -33,7 +33,7 @@ from backend.app.models.project import Project
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.discovery import is_running_in_docker
|
||||
from backend.app.services.discovery import detect_container_runtime, is_running_in_docker
|
||||
from backend.app.services.log_reader import (
|
||||
LogEntry,
|
||||
collect_sensitive_strings,
|
||||
@@ -806,6 +806,11 @@ async def _collect_support_info() -> dict:
|
||||
},
|
||||
"environment": {
|
||||
"docker": in_docker,
|
||||
# Named separately from the Docker flag: a Podman or LXC bundle
|
||||
# used to carry `"docker": false` and nothing else, which reads
|
||||
# as bare metal and hid the deployment shape a report depended on
|
||||
# (#3092).
|
||||
"container_runtime": detect_container_runtime(),
|
||||
"data_dir": _sanitize_path(str(settings.base_dir)),
|
||||
"log_dir": _sanitize_path(str(settings.log_dir)),
|
||||
"timezone": os.environ.get("TZ", ""),
|
||||
|
||||
+269
-18
@@ -671,14 +671,20 @@ async def resolve_session_max_minutes(db: AsyncSession) -> int:
|
||||
|
||||
|
||||
# --- Slicer download tokens ---
|
||||
# Short-lived, single-use tokens for slicer protocol handlers that can't send
|
||||
# auth headers. Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)
|
||||
# so they survive server restarts and work in multi-worker deployments (M-3).
|
||||
# Short-lived, resource-bound tokens for slicer protocol handlers and browser
|
||||
# downloads that can't send auth headers. Stored in AuthEphemeralToken
|
||||
# (token_type=TokenType.SLICER_DOWNLOAD) so they survive server restarts and
|
||||
# work in multi-worker deployments (M-3).
|
||||
#
|
||||
# Whether redemption consumes the token is the *caller's* choice, made at
|
||||
# verify time -- see ``verify_slicer_download_token``. The row is identical
|
||||
# either way, so a token is never "the reusable kind"; the endpoint it is
|
||||
# presented to decides.
|
||||
SLICER_TOKEN_EXPIRE_MINUTES = 5
|
||||
|
||||
|
||||
async def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
|
||||
"""Create a short-lived, single-use download token for slicer protocol handlers."""
|
||||
"""Create a short-lived download token for slicer protocol handlers."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES)
|
||||
token = secrets.token_urlsafe(24)
|
||||
@@ -703,30 +709,49 @@ async def create_slicer_download_token(resource_type: str, resource_id: int) ->
|
||||
return token
|
||||
|
||||
|
||||
async def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
|
||||
"""Verify and atomically consume a slicer download token.
|
||||
async def verify_slicer_download_token(
|
||||
token: str,
|
||||
resource_type: str,
|
||||
resource_id: int,
|
||||
*,
|
||||
single_use: bool = True,
|
||||
) -> bool:
|
||||
"""Verify a slicer download token, consuming it unless ``single_use`` is False.
|
||||
|
||||
Returns True only if the token is valid, unexpired, and bound to the given resource.
|
||||
DELETE...RETURNING ensures the token is single-use even under concurrent requests.
|
||||
|
||||
M-NEW-1 fix: nonce (resource key) is included in the WHERE clause so the DELETE
|
||||
With ``single_use=True`` (the default) redemption is a DELETE...RETURNING, which
|
||||
keeps the token one-shot even under concurrent requests. Use it wherever the
|
||||
thing being downloaded is itself consumed -- the prepared printer bundle is
|
||||
deleted once streamed, so a second redemption could only ever 404.
|
||||
|
||||
With ``single_use=False`` the token stays valid for the rest of its five-minute
|
||||
TTL. Use it for the URLs handed to an external slicer over a protocol handler:
|
||||
we do not control that process, and one-shot redemption breaks the moment
|
||||
anything fetches the URL twice -- a retry after a transient failure (Bambu
|
||||
Studio retries three times), a resumed transfer, a redirect follow, an
|
||||
on-access scanner. The first fetch would win and the slicer would be left
|
||||
with a 403 (#3029). Resource binding and expiry are unchanged; only the
|
||||
number of redemptions inside the TTL differs.
|
||||
|
||||
M-NEW-1 fix: nonce (resource key) is included in the WHERE clause so redemption
|
||||
only succeeds when the token is presented to the *correct* resource endpoint.
|
||||
Previously the token was consumed (committed) even when stored_key != expected_key,
|
||||
permanently invalidating it while returning False to the caller.
|
||||
"""
|
||||
expected_key = f"{resource_type}:{resource_id}"
|
||||
now = datetime.now(timezone.utc)
|
||||
bound = (
|
||||
AuthEphemeralToken.token == token,
|
||||
AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
|
||||
AuthEphemeralToken.nonce == expected_key,
|
||||
AuthEphemeralToken.expires_at > now,
|
||||
)
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
delete(AuthEphemeralToken)
|
||||
.where(
|
||||
AuthEphemeralToken.token == token,
|
||||
AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
|
||||
AuthEphemeralToken.nonce == expected_key,
|
||||
AuthEphemeralToken.expires_at > now,
|
||||
)
|
||||
.returning(AuthEphemeralToken.id)
|
||||
)
|
||||
if not single_use:
|
||||
result = await db.execute(select(AuthEphemeralToken.id).where(*bound))
|
||||
return result.scalar_one_or_none() is not None
|
||||
result = await db.execute(delete(AuthEphemeralToken).where(*bound).returning(AuthEphemeralToken.id))
|
||||
if result.one_or_none() is None:
|
||||
return False
|
||||
await db.commit()
|
||||
@@ -738,6 +763,11 @@ async def verify_slicer_download_token(token: str, resource_type: str, resource_
|
||||
# tags (these cannot send Authorization headers). Unlike slicer tokens they are
|
||||
# NOT single-use — streams reconnect on errors. Stored in AuthEphemeralToken
|
||||
# (token_type="camera_stream") for multi-worker compatibility (M-3).
|
||||
#
|
||||
# Anonymous by design: the row records no username, so a route guarded by this
|
||||
# token knows only "some camera viewer", never which one. That is fine for a
|
||||
# live stream, which is per-printer and not per-user, and is precisely why
|
||||
# non-camera media moved to the identified media token in #3025.
|
||||
CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
|
||||
|
||||
|
||||
@@ -893,6 +923,89 @@ async def verify_overlay_token(token: str) -> bool:
|
||||
return record is not None
|
||||
|
||||
|
||||
# --- Media tokens (#3025) ---
|
||||
# Browsers cannot attach ``Authorization`` headers to ``<img src>`` / ``<video
|
||||
# src>``, so image routes need a credential that fits in a query parameter.
|
||||
# Until #3025 they borrowed the *camera stream* token for that, which had two
|
||||
# costs: minting one requires ``camera:view``, so a user could not see a
|
||||
# library thumbnail without also being handed the live camera pointed at the
|
||||
# operator's room; and a camera-stream token records no principal at all, so
|
||||
# the thirteen non-camera routes had no identity to check ownership against
|
||||
# and returned any row to any holder.
|
||||
#
|
||||
# A media token fixes both by following the *websocket* token instead: it
|
||||
# stores the username, so ``require_media_token_*`` can resolve the real user
|
||||
# and apply the same per-row visibility gate the header-authenticated sibling
|
||||
# routes already use. Like the websocket token it is not consumed (a page of
|
||||
# thumbnails is many requests) and it outlives a password change by up to its
|
||||
# TTL -- acceptable for read-only media at 60 minutes, and identical to the
|
||||
# guarantee ``/api/v1/ws`` has made since GHSA-r2qv.
|
||||
MEDIA_TOKEN_EXPIRE_MINUTES = 60
|
||||
|
||||
|
||||
async def create_media_token(username: str | None) -> str:
|
||||
"""Create a reusable token for media (thumbnail / preview / icon) routes.
|
||||
|
||||
Records the issuing principal in ``username`` exactly as
|
||||
:func:`create_websocket_token` does. API-keyed callers reach this with
|
||||
``None`` and get the empty string, which :func:`verify_media_token`
|
||||
reports back and the dependencies then reject while auth is enabled --
|
||||
an API key has no per-row ownership identity, and it does not need one
|
||||
here because the media routes accept ``X-API-Key`` directly.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(minutes=MEDIA_TOKEN_EXPIRE_MINUTES)
|
||||
token = secrets.token_urlsafe(24)
|
||||
async with async_session() as db:
|
||||
# Prune expired tokens opportunistically (same shape as camera/websocket).
|
||||
await db.execute(
|
||||
delete(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token_type == "media",
|
||||
AuthEphemeralToken.expires_at < now,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AuthEphemeralToken(
|
||||
token=token,
|
||||
token_type="media",
|
||||
username=username or "",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
async def verify_media_token(token: str) -> str | None:
|
||||
"""Verify a media token, returning the username it was minted for.
|
||||
|
||||
Returns ``""`` for a token minted by an API key (no per-row identity) and
|
||||
``None`` when the token is missing / expired / unknown. Not consumed --
|
||||
one token serves every image on a page.
|
||||
|
||||
Deliberately narrower than :func:`verify_camera_stream_token`: no
|
||||
long-lived scope passes here. ``camera_stream`` / ``camwall`` / ``overlay``
|
||||
tokens are handed to kiosks, walls and Home Assistant to display *video*,
|
||||
and are anonymous by construction, so accepting one would reinstate the
|
||||
unowned read this token type exists to close (#3025). The inverse also
|
||||
holds -- see :func:`verify_camwall_token`, which refuses a camera-stream
|
||||
token for the same reason in the other direction.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token == token,
|
||||
AuthEphemeralToken.token_type == "media",
|
||||
AuthEphemeralToken.expires_at > now,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return row.username or ""
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against a hash.
|
||||
|
||||
@@ -2037,6 +2150,12 @@ def require_camera_stream_token_if_auth_enabled():
|
||||
Used for camera stream/snapshot endpoints that are loaded via <img> tags
|
||||
which cannot send Authorization headers. The frontend obtains a token from
|
||||
POST /printers/camera/stream-token and appends it as ?token=xxx.
|
||||
|
||||
Camera routes only. Non-camera media (thumbnails, plate previews,
|
||||
timelapses, cover images, icons) takes ``require_media_token_*``: minting a
|
||||
camera-stream token costs ``camera:view``, which no thumbnail should
|
||||
require, and the token names no principal, so a route guarded by it cannot
|
||||
tell one user's rows from another's (#3025).
|
||||
"""
|
||||
|
||||
async def checker(token: str | None = None) -> None:
|
||||
@@ -2230,3 +2349,135 @@ def require_ownership_permission(
|
||||
)
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
async def _user_from_media_token(token: str) -> User:
|
||||
"""Resolve the ``User`` a media token was minted for, or raise 401 (#3025).
|
||||
|
||||
Fail-closed on every miss: an unknown/expired token, a token minted by an
|
||||
API key (empty username -- see :func:`create_media_token`), a username no
|
||||
longer in the table, and a deactivated account all raise rather than fall
|
||||
through to an anonymous read. The 401 detail names the mint endpoint so a
|
||||
stale tab knows how to recover, and the frontend's error handler refreshes
|
||||
the token on the first failed <img> load.
|
||||
"""
|
||||
unauthorized = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Valid media token required. Obtain one from POST /api/v1/auth/media-token",
|
||||
)
|
||||
username = await verify_media_token(token)
|
||||
if not username:
|
||||
raise unauthorized
|
||||
async with async_session() as db:
|
||||
user = await get_user_by_username(db, username)
|
||||
if user is None or not user.is_active:
|
||||
raise unauthorized
|
||||
return user
|
||||
|
||||
|
||||
def require_media_token_permission(*permissions: str | Permission):
|
||||
"""Media-route dependency for resources with no per-row ownership (#3025).
|
||||
|
||||
Accepts either a ``?token=`` media token (the ``<img>`` case) or the
|
||||
ordinary ``Authorization`` / ``X-API-Key`` headers, so a ``fetch()`` or an
|
||||
API-keyed integration authenticates here exactly as it does on the
|
||||
resource's sibling routes. Requires ALL of ``permissions``, matching
|
||||
:func:`require_permission_if_auth_enabled`.
|
||||
|
||||
Returns the resolved ``User``, or ``None`` when auth is disabled or the
|
||||
caller is an API key -- the same ``User | None`` contract the header-only
|
||||
dependency has, so handlers need no new branch.
|
||||
"""
|
||||
perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
|
||||
header_checker = require_permission_if_auth_enabled(*permissions)
|
||||
|
||||
async def checker(
|
||||
token: str | None = None,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||
) -> User | None:
|
||||
async with async_session() as db:
|
||||
if not await is_auth_enabled(db):
|
||||
return None # Auth disabled, allow access
|
||||
if token:
|
||||
user = await _user_from_media_token(token)
|
||||
missing = [p for p in perm_strings if not user.has_permission(p)]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permissions: {', '.join(missing)}",
|
||||
)
|
||||
return user
|
||||
return await header_checker(credentials=credentials, x_api_key=x_api_key)
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
def require_media_token_ownership(
|
||||
all_permission: str | Permission,
|
||||
own_permission: str | Permission,
|
||||
):
|
||||
"""Media-route dependency for ownership-scoped resources (#3025).
|
||||
|
||||
The ownership counterpart of :func:`require_media_token_permission`, and
|
||||
the reason media tokens carry a principal at all: it returns the same
|
||||
``(user, can_read_all)`` pair as :func:`require_ownership_permission`, so a
|
||||
thumbnail route can hand it straight to the ``_ensure_*_visible`` gate its
|
||||
header-authenticated siblings already use instead of serving any row to any
|
||||
token holder.
|
||||
|
||||
Header callers are delegated to :func:`require_ownership_permission`
|
||||
unchanged -- including its API-key rule, where a key satisfying the ALL
|
||||
permission's scope flag gets ``can_read_all=True`` because keys have no
|
||||
per-row identity.
|
||||
"""
|
||||
all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission
|
||||
own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission
|
||||
header_checker = require_ownership_permission(all_permission, own_permission)
|
||||
|
||||
async def checker(
|
||||
token: str | None = None,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||
) -> tuple[User | None, bool]:
|
||||
async with async_session() as db:
|
||||
if not await is_auth_enabled(db):
|
||||
return None, True # Auth disabled, allow all
|
||||
if token:
|
||||
user = await _user_from_media_token(token)
|
||||
if user.has_permission(all_perm):
|
||||
return user, True
|
||||
if user.has_permission(own_perm):
|
||||
return user, False
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {own_perm} or {all_perm}",
|
||||
)
|
||||
return await header_checker(credentials=credentials, x_api_key=x_api_key)
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
def require_media_token_printer_permission(permission: str | Permission):
|
||||
"""Media-route dependency for per-printer resources (#3025).
|
||||
|
||||
:func:`require_media_token_permission` plus the API key's per-printer
|
||||
allowlist, mirroring :func:`require_printer_permission_if_auth_enabled`.
|
||||
Only the header path can present an API key -- a media token resolves to a
|
||||
real user or to nothing -- so the allowlist check applies there alone.
|
||||
"""
|
||||
media_checker = require_media_token_permission(permission)
|
||||
|
||||
async def checker(
|
||||
printer_id: int,
|
||||
token: str | None = None,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||
) -> User | None:
|
||||
user = await media_checker(token=token, credentials=credentials, x_api_key=x_api_key)
|
||||
api_key = await validated_api_key_from_request(credentials, x_api_key)
|
||||
if api_key is not None:
|
||||
check_printer_access(api_key, printer_id)
|
||||
return user
|
||||
|
||||
return checker
|
||||
|
||||
@@ -7,7 +7,7 @@ from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Application version - single source of truth
|
||||
APP_VERSION = "1.2.5.5"
|
||||
APP_VERSION = "1.2.5.6"
|
||||
GITHUB_REPO = "maziggy/bambuddy"
|
||||
BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
|
||||
|
||||
|
||||
@@ -1297,7 +1297,6 @@ async def _migrate_create_finance_tables(conn) -> None:
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
@@ -1366,7 +1365,6 @@ async def _migrate_create_finance_tables(conn) -> None:
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
@@ -1471,6 +1469,35 @@ async def _migrate_finance_money_to_numeric(conn) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _migrate_drop_wallet_currency(conn) -> None:
|
||||
"""Remove ``user_wallets.currency`` (#3123).
|
||||
|
||||
An install has one currency, held in the ``currency`` app setting. The
|
||||
column stored whatever was configured when a wallet row happened to be
|
||||
created -- and three of its four writers hardcoded "EUR" -- so it could
|
||||
only ever disagree with the setting. Everything reads the setting now, so
|
||||
the column would otherwise sit here unread -- a trap for the next person
|
||||
who finds it and assumes it means something.
|
||||
|
||||
Skipped on SQLite older than 3.35, which has no DROP COLUMN. Leaving the
|
||||
column in place there costs nothing: no code references it and it carries
|
||||
a DEFAULT, so inserts that omit it still succeed.
|
||||
"""
|
||||
if is_sqlite():
|
||||
import sqlite3
|
||||
|
||||
if sqlite3.sqlite_version_info < (3, 35, 0):
|
||||
logger.info(
|
||||
"SQLite %s has no ALTER TABLE DROP COLUMN; leaving the unused user_wallets.currency in place",
|
||||
sqlite3.sqlite_version,
|
||||
)
|
||||
return
|
||||
await _safe_execute(conn, "ALTER TABLE user_wallets DROP COLUMN currency")
|
||||
return
|
||||
|
||||
await _safe_execute(conn, "ALTER TABLE user_wallets DROP COLUMN IF EXISTS currency")
|
||||
|
||||
|
||||
async def _migrate_add_print_archive_cost_center(conn) -> None:
|
||||
"""Add the nullable cost-center link missing from pre-billing archives."""
|
||||
await _safe_execute(
|
||||
@@ -1868,6 +1895,10 @@ async def run_migrations(conn):
|
||||
await _migrate_finance_money_to_numeric(conn)
|
||||
await _migrate_create_finance_indexes(conn)
|
||||
|
||||
# Runs after the CREATE TABLE above, which used to re-add the column on an
|
||||
# install whose finance tables predate the ORM (#3123).
|
||||
await _migrate_drop_wallet_currency(conn)
|
||||
|
||||
# Migration: Add missing-spool-assignment print-start notification toggle
|
||||
try:
|
||||
async with conn.begin_nested():
|
||||
|
||||
+206
-47
@@ -115,6 +115,7 @@ from backend.app.services.obico_detection import obico_detection_service
|
||||
from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
|
||||
from backend.app.services.print_scheduler import scheduler as print_scheduler
|
||||
from backend.app.services.print_storage import (
|
||||
REASON_FTP_TRANSFER_FAILED,
|
||||
REASON_FTPS_COOLOFF,
|
||||
external_storage_present,
|
||||
ftp_probe_paths,
|
||||
@@ -146,6 +147,7 @@ from backend.app.services.spoolman_tracking import (
|
||||
)
|
||||
from backend.app.services.tasmota import tasmota_service
|
||||
from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
|
||||
from backend.app.utils.ams_humidity import ams_humidity_percent
|
||||
from backend.app.utils.filament_types import printer_filament_type
|
||||
from backend.app.utils.fts_routing import extruder_for_inlet
|
||||
from backend.app.utils.local_time import utcnow_naive
|
||||
@@ -2021,6 +2023,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
from backend.app.api.routes.inventory import _find_tray_in_ams_data
|
||||
from backend.app.models.spool import Spool as _Spool
|
||||
from backend.app.models.spool_assignment import SpoolAssignment as SA
|
||||
from backend.app.services.ams_slot_presence import spool_present
|
||||
from backend.app.services.inventory_mode import spoolman_owns_assignments
|
||||
|
||||
# Built-in assignments only. Since #2812 they survive a switch to
|
||||
@@ -2137,7 +2140,18 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
# (#1322). The state ∉ {9,10} guard keeps the firmware's
|
||||
# explicit "empty" signals authoritative over any stale
|
||||
# tray_type that might survive the relay's auto-clearing.
|
||||
loaded = cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
|
||||
#
|
||||
# tray_exist_bits comes first because that guard cannot tell
|
||||
# a firmware "empty" from Bambuddy's own: apply_tray_exist_bits
|
||||
# writes state=9 when the bit is 0 and leaves it there when the
|
||||
# bit returns. A non-RFID spool inserted into a pre-assigned
|
||||
# slot brings no tray_type with it, so the stale 9 made this
|
||||
# expression false forever and the deferred config never fired
|
||||
# — the deadlock #1322 removed from the assign path, still in
|
||||
# place here (#3084, #3100).
|
||||
loaded = spool_present(current_tray) is True or (
|
||||
cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
|
||||
)
|
||||
if not fp_type.strip() and loaded and assignment.spool:
|
||||
try:
|
||||
from backend.app.api.routes.inventory import (
|
||||
@@ -2187,6 +2201,23 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
assignment.tray_id,
|
||||
)
|
||||
continue
|
||||
# Same reasoning off the print, on firmware's own say-so:
|
||||
# a blank tray report from a slot whose tray_exist_bits
|
||||
# bit is set describes a spool the AMS cannot identify —
|
||||
# a non-RFID one, or one whose slot was reset — not a
|
||||
# spool that was taken out. Deleting the assignment there
|
||||
# threw away the identity the user had supplied, which is
|
||||
# the only place it existed (#3100). A slot the bit calls
|
||||
# empty, or one that carries no bit at all, still unlinks.
|
||||
if spool_present(current_tray) is True and not cur_color.strip() and not cur_type.strip():
|
||||
logger.info(
|
||||
"Auto-unlink skipped: spool %d AMS%d-T%d — slot still occupied, "
|
||||
"tray reports no filament data yet",
|
||||
assignment.spool_id,
|
||||
assignment.ams_id,
|
||||
assignment.tray_id,
|
||||
)
|
||||
continue
|
||||
# Fingerprint mismatch — but check if tray now matches the
|
||||
# assigned spool (e.g. auto-configure changed the tray).
|
||||
# Both sides are reduced to the type the slot can carry
|
||||
@@ -2593,6 +2624,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
||||
from backend.app.services.ams_slot_presence import spool_present
|
||||
from backend.app.services.inventory_mode import spoolman_owns_assignments
|
||||
|
||||
# Built-in remaining weight, used by sync_ams_tray only when the
|
||||
@@ -2661,7 +2693,16 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
# completion (#1459), so deleting the row mid-print
|
||||
# loses the runout segment's usage — the same failure
|
||||
# the internal inventory's auto-unlink had.
|
||||
if not printing_now:
|
||||
#
|
||||
# Nor when firmware's presence bit says the slot is
|
||||
# occupied. parse_ams_tray calls a tray with no type or
|
||||
# no colour empty, and a spool the AMS cannot read has
|
||||
# neither until something configures it — so a tag-less
|
||||
# spool assigned through the UI had its row deleted by
|
||||
# the first idle push after it was inserted. Same
|
||||
# deletion as the internal inventory's in #3100, same
|
||||
# answer, so the two modes stay in step.
|
||||
if not printing_now and spool_present(tray_data) is not True:
|
||||
empty_slots.append((ams_id, tray_id_raw))
|
||||
_clear_unknown_tag_dedup(printer_id, ams_id, tray_id_raw)
|
||||
continue
|
||||
@@ -3092,6 +3133,14 @@ async def _restore_printable_objects(printer_id: int, state, db, logger) -> None
|
||||
# armed a fresh one. Module-level so tests can shrink them.
|
||||
_FALLBACK_3MF_RETRY_DELAYS_SECONDS: tuple[float, ...] = (310.0, 620.0)
|
||||
|
||||
# Retry ladder for the other temporary give-up: the file service answered and
|
||||
# the transfer still did not finish, which at print start is usually the printer
|
||||
# serving MQTT, the camera and a job upload at the same time (#3063). Nothing has
|
||||
# to expire here, so the first attempt comes early -- #3063's reporter had the
|
||||
# same 19MB file complete 48 seconds after the download budget ran out. The later
|
||||
# two cover a printer that stays busy well into the print.
|
||||
_FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS: tuple[float, ...] = (60.0, 240.0, 600.0)
|
||||
|
||||
# printer_id -> the in-flight retry task, so print completion can cancel it.
|
||||
_fallback_3mf_retry_tasks: dict[int, asyncio.Task] = {}
|
||||
|
||||
@@ -3225,16 +3274,36 @@ async def try_recover_fallback_archive(printer_id: int, name: str, path: Path) -
|
||||
return False
|
||||
|
||||
|
||||
def _schedule_fallback_3mf_retry(printer_id: int, archive_id: int, filenames: list[str]) -> None:
|
||||
"""Re-attempt the 3MF download after the printer's FTPS cool-off clears."""
|
||||
def _schedule_fallback_3mf_retry(
|
||||
printer_id: int,
|
||||
archive_id: int,
|
||||
filenames: list[str],
|
||||
delays: tuple[float, ...] | None = None,
|
||||
reason: str = REASON_FTPS_COOLOFF,
|
||||
) -> None:
|
||||
"""Re-attempt the 3MF download after a temporary give-up.
|
||||
|
||||
``reason`` says which give-up this is, and picks the default ladder: an
|
||||
FTPS cool-off has to be waited out, while a transfer that timed out under
|
||||
contention is worth asking about again straight away (#3063). It is only
|
||||
read for the ladder and the log line -- the retry itself is identical, since
|
||||
in both cases the file is on the printer and the last attempt at it failed
|
||||
for a reason that does not last.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if delays is None:
|
||||
delays = (
|
||||
_FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS
|
||||
if reason == REASON_FTP_TRANSFER_FAILED
|
||||
else _FALLBACK_3MF_RETRY_DELAYS_SECONDS
|
||||
)
|
||||
|
||||
async def _retry() -> None:
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
for delay in _FALLBACK_3MF_RETRY_DELAYS_SECONDS:
|
||||
for delay in delays:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async with async_session() as db:
|
||||
@@ -3318,9 +3387,11 @@ def _schedule_fallback_3mf_retry(printer_id: int, archive_id: int, filenames: li
|
||||
task = asyncio.create_task(_guarded())
|
||||
_fallback_3mf_retry_tasks[printer_id] = task
|
||||
logger.info(
|
||||
"[RECOVER] Archive %s has no 3MF because printer %s was in its FTPS cool-off; will retry",
|
||||
"[RECOVER] Archive %s has no 3MF (%s) and the file should still be on printer %s; will retry in %s",
|
||||
archive_id,
|
||||
reason,
|
||||
printer_id,
|
||||
", ".join(f"{d:g}s" for d in delays),
|
||||
)
|
||||
|
||||
|
||||
@@ -4020,6 +4091,23 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
# in minutes with the file still sitting on the printer.
|
||||
blocked_by_ftps_cooloff = False
|
||||
|
||||
# Set when a probe reached the printer and still came back without the
|
||||
# file -- a timeout mid-transfer, a refused connection, anything that is
|
||||
# not a clean "not here". A 550 raises FileNotOnPrinterError and is
|
||||
# caught by name below, so a file that genuinely is not on the card
|
||||
# leaves this False and schedules nothing. Anything else means the
|
||||
# transfer, not the file, is what failed, and that does not last (#3063).
|
||||
ftp_transfer_failed = False
|
||||
|
||||
# The print's name, for a fallback archive whose `subtask_name` the
|
||||
# plate guard below had to disown. Display only, and deliberately kept
|
||||
# apart from `subtask_name`: that variable is what every file lookup
|
||||
# here is built from, and once a name has been shown to fetch another
|
||||
# plate's 3MF it must not key `_active_prints` either, or the cover
|
||||
# endpoint hands the same contradicted file to
|
||||
# `_recover_fallback_archive` and fills the row in with it (#3126).
|
||||
display_name_after_plate_reject: str | None = None
|
||||
|
||||
# Get FTP retry settings
|
||||
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
||||
|
||||
@@ -4159,11 +4247,17 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
# runs next) doesn't refetch the same 36MB over FTP.
|
||||
cache_3mf_download(printer_id, try_filename, temp_path)
|
||||
break
|
||||
# with_ftp_retry returns None once it has spent its budget,
|
||||
# and download_file_async returns False on a timeout, so an
|
||||
# exhausted transfer arrives here rather than as an
|
||||
# exception (#3063).
|
||||
ftp_transfer_failed = True
|
||||
except FileNotOnPrinterError:
|
||||
# 550 — file isn't at this path. Advance to next candidate
|
||||
# without burning the retry budget.
|
||||
logger.debug("3MF not at %s (550), trying next path", remote_path)
|
||||
except Exception as e:
|
||||
ftp_transfer_failed = True
|
||||
logger.debug("FTP download failed for %s: %s", remote_path, e)
|
||||
|
||||
if downloaded_filename or ftps_handshake_blocked(printer.ip_address):
|
||||
@@ -4236,6 +4330,9 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
logger.info("Found and downloaded from %s: %s", search_dir, fname)
|
||||
cache_3mf_download(printer_id, fname, temp_path)
|
||||
break
|
||||
# The listing named the file, so it is on the card;
|
||||
# only the transfer failed (#3063).
|
||||
ftp_transfer_failed = True
|
||||
except Exception as e:
|
||||
logger.debug("Failed to list %s: %s", search_dir, e)
|
||||
|
||||
@@ -4340,13 +4437,33 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
pass
|
||||
temp_path = None
|
||||
downloaded_filename = None
|
||||
# Override the stale subtask_name so the fallback archive's
|
||||
# print_name reflects the correct plate. Prefer the swapped
|
||||
# name when we have one; otherwise let filename win.
|
||||
if corrected_subtask:
|
||||
subtask_name = corrected_subtask
|
||||
else:
|
||||
subtask_name = ""
|
||||
# Whatever the sweep's transport did earlier, it is not why
|
||||
# this archive ends up empty: a 3MF downloaded fine, it was
|
||||
# just the wrong plate. Retrying would re-fetch that same
|
||||
# contradicted file under the same stale names and hand it
|
||||
# to _recover_fallback_archive, which checks that a
|
||||
# candidate is a readable 3MF but not which plate it is --
|
||||
# so the row would be filled in with another plate's
|
||||
# filament and cost, the exact swap #2957 removed (#3063).
|
||||
ftp_transfer_failed = False
|
||||
# Disown the name for *lookups*: it has just been shown to
|
||||
# fetch another plate's 3MF, and it keys `_active_prints`
|
||||
# below, where the cover endpoint's own download of that
|
||||
# same name would find this archive and fill it in with the
|
||||
# file we are discarding here.
|
||||
#
|
||||
# Keep it for the *title*, which is a separate question.
|
||||
# ``swap_plate_suffix`` returns None both for a name that
|
||||
# carries no "- Plate N" / "_plate_N" suffix and for no
|
||||
# name at all, and those are not the same situation: a name
|
||||
# without a suffix holds no stale plate number to be wrong
|
||||
# about. Blanking both uses at once dropped the project
|
||||
# name too, and the row fell through to the gcode_file path
|
||||
# titled "plate_1" though the real name was in hand.
|
||||
# #1204's own premise is consecutive plates *of the same
|
||||
# model*, so the project part is right either way (#3126).
|
||||
display_name_after_plate_reject = corrected_subtask or subtask_name or None
|
||||
subtask_name = corrected_subtask or ""
|
||||
|
||||
if not downloaded_filename or not temp_path:
|
||||
logger.warning("Could not find 3MF file for print: %s", filename or subtask_name)
|
||||
@@ -4355,8 +4472,25 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
try:
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
# Derive print name from subtask_name or filename
|
||||
print_name = subtask_name or filename
|
||||
# Why the card is empty. The two temporary causes outrank the
|
||||
# storage verdict because they say the sweep never got a fair
|
||||
# answer: a cool-off skipped it at the transport, and a failed
|
||||
# transfer reached the printer but never finished. Either way
|
||||
# the file is still on the card, so reporting where the printer
|
||||
# files its jobs would describe a setting that is not the
|
||||
# problem (#2957, #3063).
|
||||
if blocked_by_ftps_cooloff:
|
||||
no_3mf_reason = REASON_FTPS_COOLOFF
|
||||
elif storage.reachable and ftp_transfer_failed:
|
||||
no_3mf_reason = REASON_FTP_TRANSFER_FAILED
|
||||
else:
|
||||
no_3mf_reason = storage.reason
|
||||
|
||||
# Derive print name from subtask_name or filename. The
|
||||
# plate guard's disowned name comes second: it is a real name
|
||||
# for a real print, and only the gcode_file path is left
|
||||
# otherwise -- which titles the row "plate_1" (#3126).
|
||||
print_name = subtask_name or display_name_after_plate_reject or filename
|
||||
if print_name:
|
||||
# Clean up the name (remove extensions, path parts)
|
||||
print_name = print_name.split("/")[-1]
|
||||
@@ -4397,15 +4531,12 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
filament_color=mqtt_filament_meta.get("filament_color"),
|
||||
extra_data={
|
||||
"no_3mf_available": True,
|
||||
# Why the card is empty, when we know. The banner reads
|
||||
# this to stop telling H2/P2 owners to switch on a
|
||||
# setting that is already on and would not help (#2780).
|
||||
# A cool-off outranks the storage verdict: the sweep was
|
||||
# skipped at the transport, so the verdict never got to
|
||||
# be tested, and reporting it would blame the SD card
|
||||
# for a TLS handshake (#2957).
|
||||
"no_3mf_reason": REASON_FTPS_COOLOFF if blocked_by_ftps_cooloff else storage.reason,
|
||||
"original_subtask": subtask_name,
|
||||
# Why the card is empty, when we know -- see above. The
|
||||
# banner reads this to stop telling H2/P2 owners to
|
||||
# switch on a setting that is already on and would not
|
||||
# have helped (#2780).
|
||||
"no_3mf_reason": no_3mf_reason,
|
||||
"original_subtask": subtask_name or display_name_after_plate_reject or "",
|
||||
"_print_data": data,
|
||||
},
|
||||
)
|
||||
@@ -4465,13 +4596,15 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
except Exception as e:
|
||||
logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
|
||||
|
||||
# A cool-off give-up is temporary and the file is on the
|
||||
# printer — come back for it once the handshake block clears
|
||||
# (#2957). Deliberately not scheduled for a storage verdict:
|
||||
# a file on internal eMMC will not appear at any FTPS path
|
||||
# however long we wait, and retrying it is exactly the sweep
|
||||
# #2780 removed.
|
||||
if blocked_by_ftps_cooloff and possible_names:
|
||||
# Both temporary give-ups are worth coming back for, and for
|
||||
# the same reason: the file is on the printer and the last look
|
||||
# failed at the transport rather than finding nothing. One waits
|
||||
# out the handshake block (#2957), the other waits for the
|
||||
# printer to stop being busy (#3063). Deliberately not scheduled
|
||||
# for a storage verdict: a file on internal eMMC will not appear
|
||||
# at any FTPS path however long we wait, and retrying it is
|
||||
# exactly the sweep #2780 removed.
|
||||
if no_3mf_reason in (REASON_FTPS_COOLOFF, REASON_FTP_TRANSFER_FAILED) and possible_names:
|
||||
# `possible_names`, not the raw MQTT strings: it is the exact
|
||||
# list this flow just tried, already stripped of any path
|
||||
# (`filename` arrives as "/data/Metadata/plate_1.gcode" on
|
||||
@@ -4480,6 +4613,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
printer_id=printer_id,
|
||||
archive_id=fallback_archive.id,
|
||||
filenames=list(possible_names),
|
||||
reason=no_3mf_reason,
|
||||
)
|
||||
|
||||
# Send notification without archive data (file not found)
|
||||
@@ -7713,6 +7847,11 @@ _ams_cleanup_counter = 0 # Track recordings to trigger periodic cleanup
|
||||
# Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
|
||||
_ams_alarm_cooldown: dict[str, datetime] = {}
|
||||
AMS_ALARM_COOLDOWN_MINUTES = 60 # Don't send same alarm more than once per hour
|
||||
# (printer_id, ams_id) already reported as sending the drop index and no
|
||||
# percentage. Logged once each so a supported printer that turns out to do this
|
||||
# shows up in a support bundle rather than as a user wondering where the
|
||||
# humidity reading went -- see the note at the read site below (#3140).
|
||||
_ams_index_only_logged: set[tuple[int, int]] = set()
|
||||
|
||||
|
||||
def _resolve_temp_alarm_threshold(fair_threshold: float, raw_alarm_value: str | None) -> float:
|
||||
@@ -7950,20 +8089,30 @@ async def record_ams_history():
|
||||
for ams_data in raw_data["ams"]:
|
||||
ams_id = int(ams_data.get("id", 0))
|
||||
|
||||
# Get humidity (prefer humidity_raw)
|
||||
humidity_raw = ams_data.get("humidity_raw")
|
||||
humidity_idx = ams_data.get("humidity")
|
||||
humidity = None
|
||||
if humidity_raw is not None:
|
||||
try:
|
||||
humidity = float(humidity_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip unparseable humidity; will try fallback
|
||||
if humidity is None and humidity_idx is not None:
|
||||
try:
|
||||
humidity = float(humidity_idx)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip unparseable humidity index value
|
||||
# Percentage only. The 1-5 index is inverted, so
|
||||
# charting it as a percentage drew the wettest units as
|
||||
# the driest (#3140); a unit that reports no percentage
|
||||
# leaves a gap in the chart instead. See
|
||||
# utils/ams_humidity.
|
||||
humidity = ams_humidity_percent(ams_data)
|
||||
|
||||
# No supported printer is known to send the index
|
||||
# alone -- the report came from unsupported firmware,
|
||||
# and no install has been seen using the old fallback.
|
||||
# "Known" is doing work there, so say so once per unit:
|
||||
# the alternative is a silent blank card.
|
||||
if humidity is None and ams_data.get("humidity") is not None:
|
||||
unit_key = (printer.id, ams_id)
|
||||
if unit_key not in _ams_index_only_logged:
|
||||
_ams_index_only_logged.add(unit_key)
|
||||
logger.info(
|
||||
"[%s] AMS %d reports the 1-5 humidity index but no usable humidity_raw "
|
||||
"percentage. The index is inverted and is not shown as a percentage "
|
||||
"(#3140), so this unit has no humidity reading, chart or alarm. "
|
||||
"Please report this with the printer and AMS firmware versions.",
|
||||
printer.name,
|
||||
ams_id,
|
||||
)
|
||||
|
||||
# Get temperature
|
||||
temperature = None
|
||||
@@ -7983,7 +8132,12 @@ async def record_ams_history():
|
||||
printer_id=printer.id,
|
||||
ams_id=ams_id,
|
||||
humidity=humidity,
|
||||
humidity_raw=float(humidity_raw) if humidity_raw else None,
|
||||
# Both columns hold the same reading now that the
|
||||
# index can no longer reach ``humidity``. Writing it
|
||||
# through the same value also stops a genuine 0%
|
||||
# from being stored as NULL, which the old truthiness
|
||||
# test did.
|
||||
humidity_raw=humidity,
|
||||
temperature=temperature,
|
||||
)
|
||||
db.add(history)
|
||||
@@ -8815,7 +8969,7 @@ async def lifespan(app: FastAPI):
|
||||
import httpx as _httpx
|
||||
|
||||
from backend.app.services.bambu_cloud import set_shared_http_client
|
||||
from backend.app.services.makerworld import (
|
||||
from backend.app.services.model_providers.makerworld.service import (
|
||||
set_shared_http_client as set_shared_makerworld_http_client,
|
||||
)
|
||||
from backend.app.services.orca_cloud import (
|
||||
@@ -9375,6 +9529,11 @@ PUBLIC_API_PATTERNS = [
|
||||
# orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
|
||||
# download token in the URL path instead.
|
||||
"/dl/", # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
|
||||
# Same family, but the segment is "source-dl" — which does NOT contain "/dl/",
|
||||
# and these patterns match by substring. Without its own entry the middleware
|
||||
# 401s the slicer's header-less request before the route's token check runs,
|
||||
# so "Open source 3MF in slicer" failed whenever auth was enabled (#3029).
|
||||
"/source-dl/", # /archives/{id}/source-dl/{token}/{filename}
|
||||
# Obico ML API fetches JPEG frames by one-shot nonce (issue #172 follow-up).
|
||||
# The nonce itself is the credential: 32-byte random, single-use, ~30s TTL.
|
||||
"/obico/cached-frame/", # /obico/cached-frame/{nonce}
|
||||
|
||||
@@ -38,6 +38,13 @@ class UserWallet(Base):
|
||||
"""Per-user wallet balance.
|
||||
|
||||
Balance updates are driven by wallet transactions.
|
||||
|
||||
No currency column: an install has exactly one currency, held in the
|
||||
``currency`` app setting, and nothing here converts between currencies. The
|
||||
column that used to sit on this table recorded whatever was configured when
|
||||
the row happened to be created, three of the four writers hardcoded "EUR"
|
||||
into it, and the Finance page rendered what it found -- so an install set
|
||||
to AUD reported euros (#3123).
|
||||
"""
|
||||
|
||||
__tablename__ = "user_wallets"
|
||||
@@ -45,7 +52,6 @@ class UserWallet(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
|
||||
balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
|
||||
@@ -25,7 +25,29 @@ class LibraryFolder(Base):
|
||||
|
||||
# Link to project or archive
|
||||
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
|
||||
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
|
||||
# use_alter breaks a dependency cycle in the schema, and is not about this
|
||||
# link being special: print_archives.library_file_id -> library_files,
|
||||
# library_files.folder_id -> library_folders, and this column back to
|
||||
# print_archives. Each is reasonable alone and together they are a loop
|
||||
# SQLAlchemy cannot topologically sort, so metadata.sorted_tables dropped
|
||||
# those edges, warned on every backup and restore, and could hand back an
|
||||
# order placing a child before its parent -- which once imported
|
||||
# library_files ahead of library_folders and killed a restore on a foreign
|
||||
# key violation. Marking ONE edge for ALTER removes it from the sort graph
|
||||
# and the other two order correctly. The constraint is still created and
|
||||
# still enforced: PostgreSQL emits it as ALTER TABLE ADD CONSTRAINT (as it
|
||||
# already does for every constraint on these three tables), and SQLite,
|
||||
# which reports no ALTER support, inlines it into CREATE TABLE as before.
|
||||
# It has to be named, because an unnamed constraint cannot be ALTERed in.
|
||||
archive_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey(
|
||||
"print_archives.id",
|
||||
ondelete="SET NULL",
|
||||
use_alter=True,
|
||||
name="fk_library_folders_archive_id",
|
||||
),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
@@ -18,7 +18,11 @@ class ArchiveBase(BaseModel):
|
||||
notes: str | None = None
|
||||
cost: float | None = None
|
||||
failure_reason: str | None = None
|
||||
quantity: int | None = None # Number of items printed
|
||||
# Number of items printed. 0 is a legal answer -- a plate that jammed and
|
||||
# came off ruined produced nothing, and the project's completed-items count
|
||||
# sums this column (#3051). Bounded for the same reason as the grams below:
|
||||
# it feeds project totals, and a negative would subtract from them.
|
||||
quantity: Annotated[int | None, Field(ge=0, le=10_000)] = None
|
||||
# User-defined link (Printables, Thingiverse, etc.)
|
||||
external_url: str | None = None
|
||||
|
||||
|
||||
@@ -325,6 +325,13 @@ class AddToQueueRequest(BaseModel):
|
||||
"""Schema for adding library files to the print queue."""
|
||||
|
||||
file_ids: list[int] = Field(..., min_length=1)
|
||||
# Where the items should go. Mutually exclusive, both optional. With
|
||||
# neither, each file's own declared model is used when a printer of that
|
||||
# model is active: an item carrying no printer and no target model matches
|
||||
# neither branch of the scheduler's dispatch, so it is one nothing can ever
|
||||
# pick up (#3112).
|
||||
printer_id: int | None = None
|
||||
target_model: str | None = None
|
||||
|
||||
|
||||
class AddToQueueResult(BaseModel):
|
||||
|
||||
@@ -42,6 +42,16 @@ class MakerWorldImportRequest(BaseModel):
|
||||
...,
|
||||
description="The MakerWorld design ID (the number in /models/{id}).",
|
||||
)
|
||||
source_type: str = Field(
|
||||
default="makerworld",
|
||||
description=(
|
||||
"Which registered model provider owns the resource. Import "
|
||||
"identifies a model by numeric id rather than URL, so there is no "
|
||||
"URL for the provider registry to route on — the caller names the "
|
||||
"provider instead. Defaults to 'makerworld' so existing callers "
|
||||
"stay unchanged."
|
||||
),
|
||||
)
|
||||
profile_id: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
|
||||
@@ -301,9 +301,11 @@ class NtfyConfig(BaseModel):
|
||||
event_priorities: dict[str, int] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Per-event priority override. Keys are event names (e.g. 'on_print_failed'); "
|
||||
"values are ntfy priorities 1-5 (1=min, 2=low, 3=default, 4=high, 5=urgent). "
|
||||
"Events without an entry use ntfy's server-side default."
|
||||
"Per-event priority override. Keys are event names, either the provider's "
|
||||
"toggle column ('on_print_failed', what the UI writes) or the bare event "
|
||||
"name ('print_failed'); both are accepted. Values are ntfy priorities 1-5 "
|
||||
"(1=min, 2=low, 3=default, 4=high, 5=urgent). Events without an entry use "
|
||||
"ntfy's server-side default."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ class PrintQueueItemResponse(BaseModel):
|
||||
target_location: str | None = None # Target location filter for model-based assignment
|
||||
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
|
||||
filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
|
||||
waiting_reason: str | None = None # Why a model-based job hasn't started yet
|
||||
waiting_reason: str | None = None # Why this job hasn't started yet (empty once it can)
|
||||
archive_id: int | None # None if library_file_id is set (archive created at print start)
|
||||
library_file_id: int | None # For queue items from library files
|
||||
cost_center_id: int | None = None
|
||||
|
||||
@@ -446,10 +446,14 @@ class PrinterStatus(BaseModel):
|
||||
class DiagnosticCheck(BaseModel):
|
||||
"""One connection-diagnostic check result.
|
||||
|
||||
``id`` is a stable key (port_mqtt, port_ftps, port_rtsps, network_mode,
|
||||
subnet, mqtt_auth, developer_mode); the frontend renders the localized
|
||||
``id`` is a stable key (port_mqtt, port_ftps, port_rtsps,
|
||||
macos_local_network, network_mode, subnet, external_storage, mqtt_auth,
|
||||
developer_mode, printer_publishing); the frontend renders the localized
|
||||
title and fix text from id + status. ``params`` carries interpolation
|
||||
values (e.g. network mode, IP addresses) for that text.
|
||||
|
||||
Not every check is emitted on every run: ``macos_local_network`` appears
|
||||
only on macOS, where it is the only platform it can say anything about.
|
||||
"""
|
||||
|
||||
id: str
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Is there a spool in this AMS slot?
|
||||
|
||||
Three backend decisions turn on the answer -- whether to push
|
||||
``ams_filament_setting`` when a spool is assigned, whether a pre-assigned slot
|
||||
has just been filled, and whether an assignment has gone stale -- and all three
|
||||
used to read it off the tray's ``state`` field. That field cannot carry it.
|
||||
|
||||
## Why ``state`` is the wrong source
|
||||
|
||||
``state`` is firmware-variant. The A1 Mini BMCU and the P1S Standard AMS report
|
||||
3 for a loaded slot and never emit 11; the AMS-HT's codes differ again (#2670).
|
||||
Bambuddy already works around that on the printer card, where
|
||||
``getEmptySlotKind`` (``PrintersPage.tsx``) reads the presence bit first and
|
||||
only falls back to the 9/10 heuristic when there is no bit to read.
|
||||
|
||||
Worse, ``state`` is partly Bambuddy's own writing. ``apply_tray_exist_bits``
|
||||
sets ``state = 9`` on every slot whose presence bit is 0 -- and when the bit
|
||||
comes back it leaves the 9 exactly where it was, because the "slot occupied"
|
||||
branch only annotates ``exists`` and moves on. So a slot the firmware says is
|
||||
full can sit in the cache reading ``exists=True, state=9`` indefinitely. That
|
||||
is #3084: a non-Bambu spool is swapped in, Assign Spool reads the stale 9,
|
||||
calls the slot empty, sends no MQTT, and the printer keeps showing ``?``. The
|
||||
deferred-configuration replay could not rescue it either, because its own
|
||||
"loaded" test was the same 9/10 heuristic -- the exact deadlock #1322 removed
|
||||
elsewhere. #3100 is the same stale 9 one step further on: the replay does not
|
||||
fire, the assignment keeps the empty fingerprint it was stored with, and the
|
||||
first real tray report is read as a spool swap and deleted.
|
||||
|
||||
## What this module answers
|
||||
|
||||
``tray_exist_bits`` is the firmware's own "which slots have a spool" bitmask --
|
||||
the one BambuStudio draws its ``?`` from -- and ``apply_tray_exist_bits``
|
||||
records it per tray as ``exists``. That bit is authoritative where it exists,
|
||||
and absent otherwise; it is never a guess. Callers that want a decision for a
|
||||
payload carrying no bit at all keep their own fallback, because the right
|
||||
fallback differs per caller: the assign path wants to know whether the push is
|
||||
doomed, the unlink pass wants to know whether a spool was removed, and a state
|
||||
of 26 ("unloaded", mid-runout) answers those two questions differently.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def spool_present(tray: Mapping[str, Any] | None) -> bool | None:
|
||||
"""Does firmware's presence bit say a spool is in this slot?
|
||||
|
||||
``True`` / ``False`` straight from ``tray_exist_bits``; ``None`` when the
|
||||
tray carries no presence annotation, which means the caller has to decide
|
||||
on its own terms rather than assume either way.
|
||||
|
||||
Only the internal AMS path annotates ``exists`` (``apply_tray_exist_bits``
|
||||
is called with ``annotate_exists=True`` there and False for the VP bridge),
|
||||
so the external spool's ``vt_tray`` entries answer ``None`` -- they have no
|
||||
bit in the mask.
|
||||
"""
|
||||
if not isinstance(tray, Mapping):
|
||||
return None
|
||||
exists = tray.get("exists")
|
||||
return exists if isinstance(exists, bool) else None
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Bambu Cloud credential storage.
|
||||
|
||||
Single seam for reading and bookkeeping the stored Bambu Cloud bearer token:
|
||||
per-user columns when auth is enabled, global ``Settings`` rows otherwise
|
||||
(auth-disabled single-user installs). Lives in the services layer so feature
|
||||
packages (e.g. ``model_providers``) can consume credentials without importing
|
||||
the route layer — routes are just one consumer among several here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys for storing cloud credentials in settings
|
||||
CLOUD_TOKEN_KEY = "bambu_cloud_token"
|
||||
CLOUD_EMAIL_KEY = "bambu_cloud_email"
|
||||
CLOUD_REGION_KEY = "bambu_cloud_region"
|
||||
# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
|
||||
# an ISO timestamp; absent/empty means "not known to be dead".
|
||||
CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
|
||||
|
||||
|
||||
def _normalise_region(region: str | None) -> str:
|
||||
"""Treat NULL/empty as 'global' for legacy rows that predate the region column."""
|
||||
return region if region in ("global", "china") else "global"
|
||||
|
||||
|
||||
async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
|
||||
"""Whether the stored Bambu token is known to have been rejected.
|
||||
|
||||
Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
|
||||
cleared on a fresh login/logout. This is the only durable record we have:
|
||||
Bambu's access token is opaque (no readable expiry) and Bambuddy does not
|
||||
persist the refresh token, so without this flag a dead credential looks
|
||||
exactly like a live one.
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token_invalid_at is not None
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
return bool(row and row.value)
|
||||
|
||||
|
||||
async def mark_cloud_token_invalid(user_id: int | None) -> None:
|
||||
"""Record that Bambu rejected the stored token.
|
||||
|
||||
Opens its own session on purpose. This runs from
|
||||
``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
|
||||
is about to fail — writing through that route's session would tie the flag
|
||||
to a transaction the route may still roll back, and the fact that the
|
||||
credential is dead is true regardless of how the request ends.
|
||||
|
||||
Best-effort: a bookkeeping failure must never replace the 401 the caller
|
||||
actually needs to see. ``user_id=None`` (auth-disabled single-user setup)
|
||||
records the global flag — those installs *do* hold a token
|
||||
(:func:`get_stored_token` reads it from ``Settings``), so the rejection
|
||||
must land somewhere the status endpoints can see it.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
if user_id is not None:
|
||||
await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
|
||||
else:
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = now.isoformat()
|
||||
else:
|
||||
db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
|
||||
await db.commit()
|
||||
logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
|
||||
except Exception:
|
||||
logger.exception("Could not record the Bambu Cloud token as invalid")
|
||||
|
||||
|
||||
async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
|
||||
"""Clear the rejected-token flag — called on every fresh login and logout."""
|
||||
if user is not None:
|
||||
await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
|
||||
return
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
await db.delete(row)
|
||||
|
||||
|
||||
async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
|
||||
"""Get stored cloud token, email, and region.
|
||||
|
||||
When a user is provided (auth enabled), returns that user's per-user credentials.
|
||||
When user is None (auth disabled), falls back to global Settings table.
|
||||
Region defaults to ``"global"`` when unset (including for rows that predate the
|
||||
``cloud_region`` column).
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
|
||||
|
||||
# Fallback: global storage (auth disabled)
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
|
||||
)
|
||||
settings = {s.key: s.value for s in result.scalars().all()}
|
||||
return (
|
||||
settings.get(CLOUD_TOKEN_KEY),
|
||||
settings.get(CLOUD_EMAIL_KEY),
|
||||
_normalise_region(settings.get(CLOUD_REGION_KEY)),
|
||||
)
|
||||
@@ -554,6 +554,9 @@ class BambuFTPClient:
|
||||
# operation and cannot be overwritten by work against another printer.
|
||||
self.last_failure: FtpFailure | None = None
|
||||
self._ftp: ImplicitFTP_TLS | None = None
|
||||
# When the control socket to the printer was opened, so the close log
|
||||
# can say how long the session was held (#3009).
|
||||
self._connected_at: float | None = None
|
||||
|
||||
def _is_a1_model(self) -> bool:
|
||||
"""Check if this is an A1 series printer."""
|
||||
@@ -656,6 +659,10 @@ class BambuFTPClient:
|
||||
cap_tls_v1_2=profile.cap_tls_v1_2,
|
||||
)
|
||||
self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
|
||||
# Stamped here rather than after login: the socket exists from this
|
||||
# point on, and a session that dies during login is exactly the one
|
||||
# whose lifetime someone reading the log wants accounted for.
|
||||
self._connected_at = time.monotonic()
|
||||
logger.debug("FTP connected, logging in as bblp")
|
||||
self._ftp.login("bblp", self.access_code)
|
||||
if use_prot_c:
|
||||
@@ -677,12 +684,12 @@ class BambuFTPClient:
|
||||
except ftplib.error_perm as e:
|
||||
logger.warning("FTP connection permission error to %s: %s", self.ip_address, e)
|
||||
self.last_failure = FtpFailure(FtpFailureKind.AUTH, str(e), _ftp_reply_code(e))
|
||||
self._abandon_connection()
|
||||
self._abandon_connection("login rejected")
|
||||
return False
|
||||
except TimeoutError as e:
|
||||
logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
|
||||
self.last_failure = FtpFailure(FtpFailureKind.TIMEOUT, str(e))
|
||||
self._abandon_connection()
|
||||
self._abandon_connection("connect timed out")
|
||||
return False
|
||||
except ssl.SSLError as e:
|
||||
# Not a transient failure and not something another path or another
|
||||
@@ -711,7 +718,7 @@ class BambuFTPClient:
|
||||
# holding a failed handshake open across that is the exact thing
|
||||
# #2780's cleanup was added to stop. Idempotent, so the call that
|
||||
# used to sit at the end of this branch simply moved up.
|
||||
self._abandon_connection()
|
||||
self._abandon_connection("TLS handshake failed")
|
||||
|
||||
# Ask the printer what it actually said, once per cool-off window.
|
||||
# Checked before the deadline below is written, so a live entry here
|
||||
@@ -743,10 +750,21 @@ class BambuFTPClient:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
|
||||
self.last_failure = FtpFailure(FtpFailureKind.NETWORK, str(e), _ftp_reply_code(e))
|
||||
self._abandon_connection()
|
||||
self._abandon_connection("connect failed")
|
||||
return False
|
||||
|
||||
def _abandon_connection(self) -> None:
|
||||
def _held_for(self) -> str:
|
||||
"""How long the control socket has been open, for the close log.
|
||||
|
||||
"unknown" when :meth:`connect` never got as far as opening one -- the
|
||||
cool-off skip and a DNS/refused failure both land in
|
||||
:meth:`_abandon_connection` without a socket ever existing.
|
||||
"""
|
||||
if self._connected_at is None:
|
||||
return "unknown"
|
||||
return f"{time.monotonic() - self._connected_at:.1f}s"
|
||||
|
||||
def _abandon_connection(self, reason: str = "connection never became usable") -> None:
|
||||
"""Drop a connection that never became usable, closing its socket.
|
||||
|
||||
Every failure path in :meth:`connect` used to clear ``self._ftp`` and
|
||||
@@ -765,24 +783,50 @@ class BambuFTPClient:
|
||||
"""
|
||||
ftp = self._ftp
|
||||
self._ftp = None
|
||||
held = self._held_for()
|
||||
self._connected_at = None
|
||||
if ftp is None:
|
||||
return
|
||||
try:
|
||||
ftp.close()
|
||||
except (OSError, ftplib.Error, EOFError):
|
||||
pass # Best-effort; the socket may already be gone
|
||||
# See the note in ``disconnect``: every session that opens a socket
|
||||
# says how it closed, so the log carries matched pairs (#3009).
|
||||
logger.debug(
|
||||
"FTP session to %s closed without QUIT (%s), held %s",
|
||||
self.ip_address,
|
||||
reason,
|
||||
held,
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the FTP server."""
|
||||
if self._ftp:
|
||||
held = self._held_for()
|
||||
try:
|
||||
self._ftp.quit()
|
||||
except (OSError, ftplib.Error, EOFError):
|
||||
except (OSError, ftplib.Error, EOFError) as e:
|
||||
# ``quit()`` sends QUIT and only then closes; when the send
|
||||
# raises, ftplib never reaches its own close and the socket
|
||||
# stays open. Close it here rather than leaving it to the GC.
|
||||
self._abandon_connection()
|
||||
self._abandon_connection(f"QUIT failed: {e}")
|
||||
else:
|
||||
# One line per session, at DEBUG. Neither this method nor
|
||||
# ``_abandon_connection`` used to log anything at any level, so
|
||||
# a session closed cleanly and a socket genuinely left open
|
||||
# produced identical logs -- nothing. #3009 read that silence
|
||||
# after a print as proof the connections were never closed, and
|
||||
# nothing in the log could have shown otherwise. Now every
|
||||
# connect has a matching close, so the next person can settle it
|
||||
# from a support bundle instead of by inference.
|
||||
logger.debug(
|
||||
"FTP session to %s closed after QUIT, held %s",
|
||||
self.ip_address,
|
||||
held,
|
||||
)
|
||||
self._ftp = None
|
||||
self._connected_at = None
|
||||
|
||||
def list_files(self, path: str = "/", *, raise_on_error: bool = False) -> list[dict]:
|
||||
"""List files in a directory."""
|
||||
|
||||
@@ -24,6 +24,8 @@ import paho.mqtt.client as mqtt
|
||||
from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
|
||||
from backend.app.services.hms_errors import describe_fault
|
||||
from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
|
||||
from backend.app.utils.ams_humidity import ams_humidity_percent
|
||||
from backend.app.utils.paho_teardown import retire_paho_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,6 +86,20 @@ def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_printer_status_frame(print_data: dict) -> bool:
|
||||
"""True when a ``print`` payload is the printer reporting its own state.
|
||||
|
||||
Bambu firmware echoes a command's fields back in its acknowledgement, so a
|
||||
`project_file` ack carries whatever Bambuddy put on the wire — including
|
||||
the `cfg` bitmask and the per-job `timelapse` flag. Ingesting those as
|
||||
telemetry means reading our own request back as the printer's state
|
||||
(#3040). Only `push_status` (and the odd firmware that omits `command`
|
||||
entirely on a status frame) describes the printer.
|
||||
"""
|
||||
command = print_data.get("command")
|
||||
return command is None or command == "push_status"
|
||||
|
||||
|
||||
# ── A2L "AMS Lite" unit-id normalisation (issue capture 2026-07-20) ──────────
|
||||
# The A2L reports its 4-slot AMS Lite as physical unit **id 16**, but the
|
||||
# firmware is internally inconsistent about it:
|
||||
@@ -1596,15 +1612,14 @@ class BambuMQTTClient:
|
||||
# reconnect, mixing stale commands into the next dispatch and
|
||||
# triggering 0500_4003 SD R/W on the printer.
|
||||
#
|
||||
# Paho-network-thread callers (line ~2604/~2623 — dev-mode probe and
|
||||
# ams_filament_setting zombie detection inside `_update_state`)
|
||||
# → socket-close fallback. Calling `loop_stop()` from inside the
|
||||
# network thread would self-join and deadlock; the safe pattern is
|
||||
# to close the socket and let paho's own loop detect the broken
|
||||
# connection and auto-reconnect (same instance, same client_id —
|
||||
# queue replay is theoretically possible here but those paths have
|
||||
# always done socket-close and #1136 was specifically triggered
|
||||
# from the dispatch path).
|
||||
# Paho-network-thread callers (dev-mode probe and ams_filament_setting
|
||||
# zombie detection, both inside `_update_state`)
|
||||
# → socket-close fallback. There is no running loop on that thread to
|
||||
# hand the rebuilt client, so close the socket and let paho's own
|
||||
# loop detect the broken connection and auto-reconnect (same
|
||||
# instance, same client_id — queue replay is theoretically possible
|
||||
# here but those paths have always done socket-close and #1136 was
|
||||
# specifically triggered from the dispatch path).
|
||||
logger.warning("[%s] Forcing MQTT reconnect: %s", self.serial_number, reason)
|
||||
self._stale_reconnecting = True
|
||||
self.state.connected = False
|
||||
@@ -1615,11 +1630,11 @@ class BambuMQTTClient:
|
||||
def _reset_client_for_reconnect(self) -> None:
|
||||
"""Route between hard-reset and socket-close based on caller thread.
|
||||
|
||||
Hard-reset (preferred) requires we're not running on paho's network
|
||||
thread, since `loop_stop()` on the same thread deadlocks. Detect via
|
||||
``asyncio.get_running_loop()`` — paho's callback thread has no loop;
|
||||
every legitimate hard-reset caller (FastAPI handlers, background
|
||||
async tasks) does."""
|
||||
Hard-reset (preferred) rebuilds the client, and the rebuild needs a
|
||||
running loop to hand to ``connect()``. ``asyncio.get_running_loop()``
|
||||
answers that and identifies the caller in one go — paho's callback
|
||||
thread has no loop; every legitimate hard-reset caller (FastAPI
|
||||
handlers, background async tasks) does."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -1636,18 +1651,15 @@ class BambuMQTTClient:
|
||||
client_id, so the broker drops the old session and paho's local
|
||||
QoS 1 queue is gone. Must NOT be called from paho's network thread.
|
||||
Caller is responsible for setting ``_stale_reconnecting`` and
|
||||
broadcasting the disconnected state."""
|
||||
broadcasting the disconnected state.
|
||||
|
||||
Returns as fast as it can build a client: the old one's teardown is
|
||||
handed off rather than waited on, because waiting on it is what
|
||||
stopped the event loop in #3068. See ``retire_paho_client``."""
|
||||
old_client = self._client
|
||||
self._client = None
|
||||
if old_client is not None:
|
||||
try:
|
||||
old_client.disconnect() # MQTT DISCONNECT — broker drops session
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
old_client.loop_stop() # blocks briefly until the network thread exits
|
||||
except Exception:
|
||||
pass
|
||||
retire_paho_client(old_client, self.serial_number)
|
||||
# Skip reconnect if no asyncio loop is available (test environment or
|
||||
# pre-init). The next initial connect() call from PrinterManager will
|
||||
# set up the client fresh.
|
||||
@@ -2200,7 +2212,15 @@ class BambuMQTTClient:
|
||||
# next 1-2 push_status frames may still carry the printer's OLD cfg
|
||||
# for ~3 s before the firmware reflects the change. Without this
|
||||
# gate the UI would flicker ON→OFF→ON. Same pattern xcam uses.
|
||||
new_backup = parse_ams_filament_backup_from_cfg(print_data.get("cfg"))
|
||||
# Only from a status frame: a project_file ack echoes our own
|
||||
# `"cfg": "0"` back, which read as "printer says backup is OFF" and
|
||||
# stuck on every family that doesn't repeat `cfg` in its periodic
|
||||
# frames — P1S, A1, A1 Mini, A2L (#3040).
|
||||
new_backup = (
|
||||
parse_ams_filament_backup_from_cfg(print_data.get("cfg"))
|
||||
if is_printer_status_frame(print_data)
|
||||
else None
|
||||
)
|
||||
if new_backup is not None and new_backup != self.state.ams_filament_backup:
|
||||
hold_start = self._xcam_hold_start.get("print_option_auto_switch_filament")
|
||||
if hold_start is not None and (time.time() - hold_start) <= self._xcam_hold_time:
|
||||
@@ -3677,7 +3697,7 @@ class BambuMQTTClient:
|
||||
cycle ending at 63 degC with the reading still above the threshold is
|
||||
the whole shape of the re-arm loop.
|
||||
"""
|
||||
box = f"temp={ams_unit.get('temp')} humidity={ams_unit.get('humidity_raw', ams_unit.get('humidity'))}"
|
||||
box = f"temp={ams_unit.get('temp')} humidity={ams_humidity_percent(ams_unit)}"
|
||||
if ams_id in self._drying_stops_sent:
|
||||
self._drying_stops_sent.discard(ams_id)
|
||||
logger.info(
|
||||
@@ -4947,8 +4967,10 @@ class BambuMQTTClient:
|
||||
except (ValueError, TypeError):
|
||||
logger.debug("[%s] could not parse stat field: %r", self.serial_number, data["stat"])
|
||||
|
||||
# Parse timelapse status (recording active during print)
|
||||
if "timelapse" in data:
|
||||
# Parse timelapse status (recording active during print). Status frames
|
||||
# only — the project_file ack echoes back the per-job timelapse flag we
|
||||
# asked for, which is a request, not the recorder's state (#3040).
|
||||
if "timelapse" in data and is_printer_status_frame(data):
|
||||
logger.debug("[%s] timelapse field: %s", self.serial_number, data["timelapse"])
|
||||
self.state.timelapse = data["timelapse"] is True
|
||||
# Track if timelapse was ever active during this print
|
||||
@@ -6001,7 +6023,11 @@ class BambuMQTTClient:
|
||||
"vibration_cali": vibration_cali,
|
||||
"layer_inspect": layer_inspect,
|
||||
"use_ams": use_ams,
|
||||
"cfg": "0",
|
||||
# No "cfg": it is the printer's device-config bitmask
|
||||
# (auto-refill, detect-on-insert, chamber light, ...), not a
|
||||
# per-job field — BambuStudio's PrintParams has no such
|
||||
# member. We used to send "0"; firmware ignores it, but it
|
||||
# comes straight back in the project_file ack (#3040).
|
||||
# extrude_cali_flag gates flow-dynamics calibration:
|
||||
# 0 = never, 1 = force every print, 2 = auto (run only if the
|
||||
# filament wasn't calibrated recently). #1721 saw stage 8
|
||||
@@ -6356,14 +6382,32 @@ class BambuMQTTClient:
|
||||
return True
|
||||
|
||||
def disconnect(self, timeout: float = 0):
|
||||
"""Disconnect from the printer."""
|
||||
"""Disconnect from the printer.
|
||||
|
||||
Waits up to *timeout* for paho to report the disconnect, then lets the
|
||||
client go without joining its network thread — the callers are route
|
||||
handlers (printer edited, deleted, disconnected by hand) running on the
|
||||
asyncio thread, and that join has no bound (#3068)."""
|
||||
if self._client:
|
||||
old_client = self._client
|
||||
self._disconnection_event = threading.Event()
|
||||
self._client.disconnect()
|
||||
old_client.disconnect()
|
||||
# The callback that sets this fires on paho's thread, so it has to
|
||||
# be given its window before retire_paho_client detaches it.
|
||||
self._disconnection_event.wait(timeout=timeout)
|
||||
self._client.loop_stop()
|
||||
self._client = None
|
||||
retire_paho_client(old_client, self.serial_number)
|
||||
self.state.connected = False
|
||||
# Deliberately no on_state_change here. paho's disconnect callback
|
||||
# used to land during the join, but `_on_disconnect` suppresses
|
||||
# itself for a clean disconnect of a printer that reported within
|
||||
# the last 10s -- which is every healthy printer -- so a
|
||||
# hand-disconnected printer never broadcast one. Announcing it now
|
||||
# would fire the connected→disconnected edge in
|
||||
# `on_printer_status_change` and notify the user their printer went
|
||||
# offline a minute after they disconnected it on purpose (#1752).
|
||||
# The callers drop the client from the manager anyway, so the next
|
||||
# status read already shows it gone.
|
||||
|
||||
def send_command(self, command: dict):
|
||||
"""Send a command to the printer."""
|
||||
|
||||
@@ -178,7 +178,7 @@ def _mask_string(value: str, sensitive_strings: dict[str, str]) -> str:
|
||||
Known values are matched first (longest first so "My Printer 1" beats
|
||||
"My Printer"); the regex pass then catches any IPs the sensitive_strings
|
||||
table didn't already cover — most importantly the Bambuddy host's own
|
||||
IP (returned by ``_get_host_ip`` inside the diagnostic, not in the DB)
|
||||
IP (returned by ``_host_source_ip`` inside the diagnostic, not in the DB)
|
||||
and any virtual-printer ``bind_ip`` the user picked at setup.
|
||||
"""
|
||||
if not value:
|
||||
|
||||
@@ -23,8 +23,106 @@ from pathlib import Path
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Runtime names :func:`detect_container_runtime` can return. These reach the
|
||||
# user in the connection diagnostic, so they are the names people know their
|
||||
# own setup by.
|
||||
RUNTIME_DOCKER = "Docker"
|
||||
RUNTIME_PODMAN = "Podman"
|
||||
RUNTIME_KUBERNETES = "Kubernetes"
|
||||
RUNTIME_CONTAINERD = "containerd"
|
||||
RUNTIME_LXC = "LXC"
|
||||
# Sentinel for "in a container we cannot name". The others are proper nouns
|
||||
# that interpolate into any language; this one is localized by the frontend
|
||||
# (diagnostic.check.network_mode.genericRuntime), so keep the two in step.
|
||||
RUNTIME_OTHER = "container"
|
||||
|
||||
# Runtimes that put Bambuddy in an OCI container whose network mode is a
|
||||
# choice the user made and can change. LXC is deliberately absent: a Proxmox
|
||||
# or LXD system container is bridged onto the LAN like a small VM, so there is
|
||||
# no "recreate it with host networking" advice to give.
|
||||
OCI_RUNTIMES = frozenset({RUNTIME_DOCKER, RUNTIME_PODMAN, RUNTIME_KUBERNETES, RUNTIME_CONTAINERD, RUNTIME_OTHER})
|
||||
|
||||
# systemd writes the engine's own name here. It is the only signal that tells
|
||||
# Podman apart from Docker without guessing, which is why it is consulted
|
||||
# first (see updates.py, which has used it for the same reason for longer).
|
||||
_SYSTEMD_CONTAINER = Path("/run/systemd/container")
|
||||
|
||||
_SYSTEMD_RUNTIME_NAMES = {
|
||||
"docker": RUNTIME_DOCKER,
|
||||
"podman": RUNTIME_PODMAN,
|
||||
"containerd": RUNTIME_CONTAINERD,
|
||||
"lxc": RUNTIME_LXC,
|
||||
"lxc-libvirt": RUNTIME_LXC,
|
||||
"oci": RUNTIME_OTHER,
|
||||
}
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
"""Read a small /proc or /run marker file, empty string if unreadable."""
|
||||
try:
|
||||
return path.read_text()
|
||||
except (OSError, ValueError):
|
||||
# Unreadable, absent, or /proc entry that vanished mid-read.
|
||||
return ""
|
||||
|
||||
|
||||
def detect_container_runtime() -> str | None:
|
||||
"""Name the container runtime Bambuddy is running under, or None.
|
||||
|
||||
``is_running_in_docker`` below answers a narrower question and is
|
||||
deliberately left alone — see the comment on it.
|
||||
|
||||
Detection is ordered most-specific first, because the generic markers
|
||||
cannot tell two engines apart: Podman sets ``/run/.containerenv`` *and*
|
||||
writes ``libpod`` into the cgroup path, while Docker sets ``/.dockerenv``
|
||||
and writes ``docker``. A container started by neither still gets a name
|
||||
(``container``) rather than None, because "we are in something" is a
|
||||
useful answer even when the engine is not.
|
||||
"""
|
||||
systemd_name = _read_text(_SYSTEMD_CONTAINER).strip().lower()
|
||||
if systemd_name:
|
||||
return _SYSTEMD_RUNTIME_NAMES.get(systemd_name, RUNTIME_OTHER)
|
||||
|
||||
# Podman writes /run/.containerenv into every container it starts. Older
|
||||
# versions put it at the root, so both are checked.
|
||||
if Path("/run/.containerenv").exists() or Path("/.containerenv").exists():
|
||||
return RUNTIME_PODMAN
|
||||
if Path("/.dockerenv").exists():
|
||||
return RUNTIME_DOCKER
|
||||
|
||||
cgroup = _read_text(Path("/proc/1/cgroup"))
|
||||
if "libpod" in cgroup:
|
||||
return RUNTIME_PODMAN
|
||||
if "kubepods" in cgroup:
|
||||
return RUNTIME_KUBERNETES
|
||||
if "docker" in cgroup:
|
||||
return RUNTIME_DOCKER
|
||||
if "containerd" in cgroup:
|
||||
return RUNTIME_CONTAINERD
|
||||
if "/lxc" in cgroup:
|
||||
return RUNTIME_LXC
|
||||
|
||||
env_name = (os.environ.get("CONTAINER") or "").strip().lower()
|
||||
if env_name:
|
||||
return _SYSTEMD_RUNTIME_NAMES.get(env_name, RUNTIME_OTHER)
|
||||
if os.environ.get("DOCKER_CONTAINER"):
|
||||
return RUNTIME_DOCKER
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_running_in_docker() -> bool:
|
||||
"""Detect if we're running inside a Docker container."""
|
||||
"""Detect if we're running inside a Docker container.
|
||||
|
||||
Kept Docker-specific on purpose, and NOT rewritten on top of
|
||||
:func:`detect_container_runtime`. Three callers key real behaviour off
|
||||
this: ``/api/discovery/info`` feeds it to the Add-Printer flow, where
|
||||
``isDocker`` switches discovery from SSDP to subnet scanning, and the
|
||||
backup-path probe and support bundle both read it. Answering True for a
|
||||
host-networked Podman container would take SSDP away from users for whom
|
||||
it works (#3092). Widening it is a separate decision from naming the
|
||||
runtime, so it is made separately.
|
||||
"""
|
||||
# Check for .dockerenv file
|
||||
if Path("/.dockerenv").exists():
|
||||
return True
|
||||
|
||||
@@ -1112,10 +1112,18 @@ async def _stream_rtsp(
|
||||
"1024000",
|
||||
"-max_delay",
|
||||
"500000",
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
# No probe cap here (#3082). The input is whatever camera the user
|
||||
# owns, so there is no stream to tune a fast-start probe against: a
|
||||
# 32-byte probe expires before a source that carries SPS/PPS in-band
|
||||
# rather than in its SDP has sent them, and ffmpeg then starts no
|
||||
# H.264 decoder and emits nothing at all. ffmpeg's defaults are a
|
||||
# ceiling rather than a wait, so a camera that announces itself in the
|
||||
# first packet still starts as fast as it ever did.
|
||||
#
|
||||
# `_capture_rtsp_frame` has always run on those defaults, which is how
|
||||
# a camera could pass the connection test and still show a black live
|
||||
# view. The printer path is the opposite case — a known Bambu camera
|
||||
# per model — and keeps its tuning in `camera_profiles.py`.
|
||||
"-fflags",
|
||||
"nobuffer",
|
||||
"-flags",
|
||||
|
||||
@@ -4,6 +4,24 @@ from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
|
||||
from backend.app.models.settings import Settings as AppSettingModel
|
||||
from backend.app.schemas.settings import AppSettings as AppSettingsSchema
|
||||
|
||||
|
||||
async def resolve_configured_currency(db: AsyncSession) -> str:
|
||||
"""The currency this install reports balances in.
|
||||
|
||||
Every other surface in Bambuddy renders the ``currency`` app setting.
|
||||
Finance used to answer from ``user_wallets.currency``, which three of its
|
||||
four writers filled with a hardcoded "EUR", so an install configured for
|
||||
AUD reported a euro balance (#3123). That column is gone; this is the one
|
||||
place that answers the question.
|
||||
"""
|
||||
result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.value:
|
||||
return setting.value
|
||||
return AppSettingsSchema().currency
|
||||
|
||||
|
||||
def transaction_affects_personal_balance(
|
||||
|
||||
@@ -220,7 +220,7 @@ async def apply_print_charge_for_archive(
|
||||
|
||||
wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
|
||||
if wallet is None:
|
||||
wallet = UserWallet(user_id=actual_user_id, balance=0.0, currency="EUR")
|
||||
wallet = UserWallet(user_id=actual_user_id, balance=0.0)
|
||||
db.add(wallet)
|
||||
await db.flush()
|
||||
logger.info("Created new wallet for user ID %s.", actual_user_id)
|
||||
|
||||
@@ -2,9 +2,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
|
||||
from backend.app.models.settings import Settings as AppSettingModel
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.settings import AppSettings as AppSettingsSchema
|
||||
|
||||
|
||||
async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
|
||||
@@ -16,12 +14,7 @@ async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
|
||||
|
||||
wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user.id))).scalar_one_or_none()
|
||||
if wallet is None:
|
||||
# Respect admin-configured currency if present, otherwise fall back to app default
|
||||
default_currency = AppSettingsSchema().currency
|
||||
result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
|
||||
setting = result.scalar_one_or_none()
|
||||
currency = setting.value if setting and setting.value else default_currency
|
||||
db.add(UserWallet(user_id=user.id, balance=0.0, currency=currency))
|
||||
db.add(UserWallet(user_id=user.id, balance=0.0))
|
||||
changed = True
|
||||
|
||||
private_center = (
|
||||
|
||||
@@ -535,8 +535,8 @@ class GitHubBackupService:
|
||||
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
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
bambu: list = []
|
||||
orca: list = []
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Model-provider interface + registry.
|
||||
|
||||
The shared seam for "import a model from a 3D model website". Providers
|
||||
implement the interface (a ``ModelProvider`` descriptor + a per-request
|
||||
``ProviderService`` transport) and register an instance here; the registry
|
||||
routes pasted URLs to the owning provider via ``find_for_url``. MakerWorld is
|
||||
the first (and currently only) registered provider.
|
||||
"""
|
||||
|
||||
from backend.app.services.model_providers.base import (
|
||||
ModelProvider,
|
||||
ProviderAuthConfig,
|
||||
ProviderAuthError,
|
||||
ProviderAuthType,
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderError,
|
||||
ProviderForbiddenError,
|
||||
ProviderNotFoundError,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
ProviderService,
|
||||
ProviderStatus,
|
||||
ProviderUnavailableError,
|
||||
ProviderUrlError,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld import makerworld_provider
|
||||
from backend.app.services.model_providers.registry import ModelProviderRegistry, registry
|
||||
|
||||
registry.register(makerworld_provider)
|
||||
|
||||
__all__ = [
|
||||
"ModelProvider",
|
||||
"ModelProviderRegistry",
|
||||
"ProviderAuthConfig",
|
||||
"ProviderAuthError",
|
||||
"ProviderAuthType",
|
||||
"ProviderDownload",
|
||||
"ProviderDownloadInfo",
|
||||
"ProviderError",
|
||||
"ProviderForbiddenError",
|
||||
"ProviderNotFoundError",
|
||||
"ProviderResolvedModel",
|
||||
"ProviderResourceRef",
|
||||
"ProviderService",
|
||||
"ProviderStatus",
|
||||
"ProviderUnavailableError",
|
||||
"ProviderUrlError",
|
||||
"makerworld_provider",
|
||||
"registry",
|
||||
]
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Model-provider interface.
|
||||
|
||||
A *model provider* is a website that hosts 3D printer models (MakerWorld,
|
||||
Thingiverse, Printables, ...) whose files Bambuddy can resolve and import
|
||||
into the library. This module defines the contract every provider must
|
||||
fulfil — the split being:
|
||||
|
||||
* :class:`ModelProvider` — the static, provider-wide descriptor: identity
|
||||
(``source_type``, ``display_name``), URL routing (``host_patterns``),
|
||||
the auth it needs (or explicitly doesn't), and a factory that builds a
|
||||
per-request :class:`ProviderService` seeded with the caller's stored
|
||||
credentials.
|
||||
* :class:`ProviderService` — one HTTP client per request, mirroring the
|
||||
``BambuCloudService`` construction pattern: resolve a model URL to
|
||||
metadata + importable files, resolve + fetch a concrete download, and
|
||||
proxy thumbnail images. Providers are *thin transports*: shared concerns
|
||||
(library dedupe, folder auto-creation, ``save_3mf_bytes_to_library``)
|
||||
stay in the route layer so every provider benefits from them.
|
||||
|
||||
The interface deliberately covers everything the MakerWorld integration
|
||||
needs today (see ``model_providers/makerworld/``) so that adding a new site
|
||||
is: implement ``ModelProvider`` + ``ProviderService``, register it, and the
|
||||
shared import API routes pasted URLs to it via ``registry.find_for_url``.
|
||||
|
||||
Only interoperability — not affiliated with or endorsed by MakerWorld or any
|
||||
other provider, and not intended to circumvent any access control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.app.core.compat import StrEnum
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
|
||||
|
||||
class ProviderAuthType(StrEnum):
|
||||
"""The kind of credentials a model provider may (optionally) require."""
|
||||
|
||||
NONE = "none"
|
||||
ACCESS_TOKEN = "access_token"
|
||||
USERNAME_PASSWORD = "username_password"
|
||||
BAMBU_CLOUD_BEARER = "bambu_cloud_bearer" # MakerWorld today: shared Bambu Cloud token
|
||||
COOKIE = "cookie" # reserved for sites without a first-party API
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderAuthConfig:
|
||||
"""Declarative description of a provider's authentication requirement.
|
||||
|
||||
Describes *what* the provider needs so the UI can prompt for it; the
|
||||
actual storage/retrieval of credentials stays provider-specific for now
|
||||
(MakerWorld reads the Bambu Cloud token the user already configured).
|
||||
``credential_fields`` names the inputs a future generic credential vault
|
||||
would collect (e.g. ``("access_token",)`` or ``("username", "password")``).
|
||||
"""
|
||||
|
||||
auth_type: ProviderAuthType
|
||||
display_label: str
|
||||
description: str = ""
|
||||
credential_fields: tuple[str, ...] = ()
|
||||
setup_hint: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderResourceRef:
|
||||
"""Provider-agnostic handle for one model resource.
|
||||
|
||||
``external_id`` is the provider-native model identifier (MakerWorld's
|
||||
integer design id as a string); ``sub_id`` is an optional secondary key
|
||||
such as MakerWorld's ``profileId`` for a specific plate.
|
||||
|
||||
Both ids must be **numeric strings** today: the shared route layer casts
|
||||
them with ``int()`` when shaping API responses. Providers whose native
|
||||
ids are not numeric need route-layer changes first — keep this contract
|
||||
in mind when implementing one.
|
||||
"""
|
||||
|
||||
source_type: str
|
||||
external_id: str
|
||||
sub_id: str | None = None
|
||||
original_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderStatus:
|
||||
"""Whether the caller can use this provider right now.
|
||||
|
||||
``auth_error`` carries a human-readable reason when the caller is signed
|
||||
in but the stored credential has been rejected (e.g. expired); ``None``
|
||||
when there is no error to report. ``credential_rejected`` is the
|
||||
machine-readable counterpart — set exactly when the stored credential
|
||||
exists *and* was refused by the provider — so callers (e.g. a route
|
||||
reporting "sign-in expired") never have to infer it from ``auth_error``,
|
||||
which may legitimately be set for other failures (network, rate limit).
|
||||
"""
|
||||
|
||||
authenticated: bool
|
||||
can_download: bool
|
||||
auth_error: str | None = None
|
||||
credential_rejected: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResolvedModel:
|
||||
"""Result of resolving a model URL.
|
||||
|
||||
``design`` and ``instances`` are provider-specific dicts passed through
|
||||
verbatim — the frontend reads fields a provider may add over time, so we
|
||||
don't re-shape them here. Which library rows already hold this resource
|
||||
is the route layer's concern (it owns the library query) and stays out of
|
||||
the resolved payload.
|
||||
"""
|
||||
|
||||
ref: ProviderResourceRef
|
||||
design: dict[str, Any]
|
||||
instances: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDownloadInfo:
|
||||
"""A concrete, short-lived download for one file/plate.
|
||||
|
||||
``ref`` may be enriched by the provider with the ``sub_id`` it resolved
|
||||
(e.g. the actual MakerWorld profile selected when the caller omitted
|
||||
one) so the route can build the canonical dedupe URL.
|
||||
"""
|
||||
|
||||
ref: ProviderResourceRef
|
||||
url: str
|
||||
suggested_filename: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderDownload:
|
||||
"""Downloaded file bytes plus the final suggested filename."""
|
||||
|
||||
file_bytes: bytes
|
||||
filename: str
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""Base exception for model-provider API errors."""
|
||||
|
||||
|
||||
class ProviderAuthError(ProviderError):
|
||||
"""Raised when a provider requires credentials and we have none (or the
|
||||
stored one was rejected). True auth failure."""
|
||||
|
||||
|
||||
class ProviderForbiddenError(ProviderError):
|
||||
"""Raised when a provider refuses access despite valid authentication —
|
||||
content-gated (purchase/points required, region restricted, ...)."""
|
||||
|
||||
|
||||
class ProviderNotFoundError(ProviderError):
|
||||
"""Raised when a model / file / profile doesn't exist."""
|
||||
|
||||
|
||||
class ProviderUnavailableError(ProviderError):
|
||||
"""Raised on 5xx, network errors, or malformed payloads."""
|
||||
|
||||
|
||||
class ProviderUrlError(ProviderError):
|
||||
"""Raised when a URL isn't a model page of this provider."""
|
||||
|
||||
|
||||
class ModelProvider(ABC):
|
||||
"""Static descriptor + factory for one model-hosting site.
|
||||
|
||||
Instances are shared (one per provider); all mutable state lives in the
|
||||
per-request :class:`ProviderService` built by :meth:`build_service`.
|
||||
"""
|
||||
|
||||
source_type: str
|
||||
display_name: str
|
||||
host_patterns: tuple[str, ...] = ()
|
||||
auth: ProviderAuthConfig | None = None
|
||||
#: Top-level library folder imports land in when the caller names no
|
||||
#: folder. ``None`` imports into the library root — the route will not
|
||||
#: mint a folder without a name.
|
||||
default_folder_name: str | None = None
|
||||
#: The permissions the routes enforce for this provider's read and import
|
||||
#: operations. Optional only so the base class has a default: a provider
|
||||
#: that leaves them unset is refused at the gate rather than treated as
|
||||
#: unrestricted (see ``makerworld._authorize_for_provider``).
|
||||
view_permission: Permission | None = None
|
||||
import_permission: Permission | None = None
|
||||
|
||||
@abstractmethod
|
||||
async def build_service(
|
||||
self,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
api_key_owner: User | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> ProviderService:
|
||||
"""Build a per-request service seeded with the caller's credentials.
|
||||
|
||||
``api_key_owner`` is the API key's owning user for API-keyed calls
|
||||
(see ``resolve_api_key_cloud_owner``); providers use it as the
|
||||
fallback identity when ``user`` is None.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def parse_url(self, url: str) -> ProviderResourceRef:
|
||||
"""Extract a :class:`ProviderResourceRef` from a model URL.
|
||||
|
||||
Raises :class:`ProviderUrlError` when the URL isn't a model page of
|
||||
this provider.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def canonical_url(self, ref: ProviderResourceRef) -> str:
|
||||
"""Stable dedupe key for a resource (library ``source_url``).
|
||||
|
||||
All URL variants of the same resource must collapse to this string;
|
||||
different resources (e.g. different plates of one model) must differ.
|
||||
"""
|
||||
|
||||
def source_url_filter(self, column: Any, external_id: str) -> Any:
|
||||
"""SQL predicate over ``LibraryFile.source_url`` selecting every row
|
||||
that belongs to this resource — the whole-model canonical URL plus,
|
||||
when the provider keys dedupe per sub-resource (plate/profile), every
|
||||
such variant. Drives the resolve flow's already-imported detection.
|
||||
|
||||
The default matches the model-level canonical URL only; providers with
|
||||
recognisable per-plate URL shapes override this (see MakerWorld).
|
||||
"""
|
||||
prefix = self.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
|
||||
return column == prefix
|
||||
|
||||
def supports_url(self, url: str) -> bool:
|
||||
"""Whether ``url`` points at this provider (host-suffix match).
|
||||
|
||||
Accepts scheme-less input (``makerworld.com/models/1``) the same way
|
||||
:meth:`parse_url` does, so ``find_for_url`` routes exactly the URLs
|
||||
the provider will then accept.
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
candidate = url.strip()
|
||||
if "://" not in candidate:
|
||||
candidate = "https://" + candidate
|
||||
try:
|
||||
host = (urlparse(candidate).hostname or "").lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return any(host == pattern or host.endswith("." + pattern) for pattern in self.host_patterns)
|
||||
|
||||
def thumbnail_hosts(self) -> tuple[str, ...]:
|
||||
"""Hosts whose image URLs may be proxied by ``fetch_thumbnail``.
|
||||
|
||||
Serves as the SSRF allowlist for the provider's image proxy; empty
|
||||
means the provider has no server-side thumbnail proxy.
|
||||
"""
|
||||
return ()
|
||||
|
||||
def download_hosts(self) -> tuple[str, ...]:
|
||||
"""Hosts whose file URLs may be fetched by the download path.
|
||||
|
||||
Serves as the SSRF allowlist for :meth:`ProviderService.download`,
|
||||
symmetric to :meth:`thumbnail_hosts`; empty means the provider has no
|
||||
server-side file fetch (so no allowlist constraint applies). Providers
|
||||
whose service fetches files must override this — a new provider gets
|
||||
the same structural hint the thumbnail proxy gives its counterpart.
|
||||
"""
|
||||
return ()
|
||||
|
||||
|
||||
class ProviderService(ABC):
|
||||
"""Per-request client for a single provider.
|
||||
|
||||
Built by :meth:`ModelProvider.build_service`, never constructed directly.
|
||||
Providers must be closed after use (:meth:`close`); the shared connection
|
||||
pool is only closed by the owner.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Close the client if this service instance owns it."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(self, db: AsyncSession) -> ProviderStatus:
|
||||
"""Report whether the caller can use this provider (credential state)."""
|
||||
|
||||
@abstractmethod
|
||||
async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
|
||||
"""Fetch metadata + the importable file/plate list for a resource."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
|
||||
"""Resolve the concrete download for a resource/file.
|
||||
|
||||
May need provider-specific lookups (e.g. MakerWorld's alphanumeric
|
||||
``modelId``) and must enrich ``ref.sub_id`` with the actually-resolved
|
||||
file/plate so the route can build the canonical dedupe key.
|
||||
Raises ``ProviderAuthError`` when the provider requires credentials
|
||||
and the caller has none.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
|
||||
"""Fetch the file bytes for a :class:`ProviderDownloadInfo`.
|
||||
|
||||
Must restrict the upstream URL host to :meth:`ModelProvider.download_hosts`
|
||||
(SSRF guard — the symmetric counterpart to ``fetch_thumbnail``).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
|
||||
"""Proxy a provider CDN image, returning ``(bytes, content_type)``.
|
||||
|
||||
Must restrict the upstream host to :meth:`ModelProvider.thumbnail_hosts`
|
||||
(SSRF guard).
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
"""MakerWorld model provider package.
|
||||
|
||||
Exports the provider instance the registry consumes; the implementation lives
|
||||
in the sibling modules (``service``, ``http``, ``url``, ``errors``, ``auth``).
|
||||
"""
|
||||
|
||||
from backend.app.services.model_providers.makerworld.provider import (
|
||||
MakerWorldProvider,
|
||||
makerworld_provider,
|
||||
)
|
||||
|
||||
__all__ = ["MakerWorldProvider", "makerworld_provider"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""MakerWorld credential handling.
|
||||
|
||||
MakerWorld downloads run on the same Bambu Cloud bearer token as the rest of
|
||||
the Bambu cloud integration — there is no separate MakerWorld OAuth flow. This
|
||||
module is the single seam where the MakerWorld provider reads the caller's
|
||||
stored token, reports a rejected/expired credential, and records a 401 so the
|
||||
whole app agrees the sign-in is dead (see ``cloud.mark_cloud_token_invalid``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
get_stored_token,
|
||||
is_cloud_token_invalid,
|
||||
mark_cloud_token_invalid,
|
||||
)
|
||||
|
||||
__all__ = ["get_stored_token", "is_cloud_token_invalid", "mark_cloud_token_invalid"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""MakerWorld error types.
|
||||
|
||||
Subclasses of the generic provider hierarchy so route layers can map errors
|
||||
with the provider-agnostic classes (:class:`ProviderError` and friends) while
|
||||
callers that know they're talking to MakerWorld get the specific types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.app.services.model_providers.base import (
|
||||
ProviderAuthError,
|
||||
ProviderError,
|
||||
ProviderForbiddenError,
|
||||
ProviderNotFoundError,
|
||||
ProviderUnavailableError,
|
||||
ProviderUrlError,
|
||||
)
|
||||
|
||||
|
||||
class MakerWorldError(ProviderError):
|
||||
"""Base exception for MakerWorld API errors."""
|
||||
|
||||
|
||||
class MakerWorldAuthError(ProviderAuthError, MakerWorldError):
|
||||
"""Raised when MakerWorld requires a Bambu Cloud token and we don't have
|
||||
one (or the one we sent was rejected). True auth failure."""
|
||||
|
||||
|
||||
class MakerWorldForbiddenError(ProviderForbiddenError, MakerWorldError):
|
||||
"""Raised when MakerWorld refuses access despite valid authentication —
|
||||
content-gated (points required, purchase required, region restricted,
|
||||
early-access, etc.)."""
|
||||
|
||||
|
||||
class MakerWorldNotFoundError(ProviderNotFoundError, MakerWorldError):
|
||||
"""Raised when a design / profile / instance doesn't exist."""
|
||||
|
||||
|
||||
class MakerWorldUnavailableError(ProviderUnavailableError, MakerWorldError):
|
||||
"""Raised on 5xx, network errors, or malformed payloads."""
|
||||
|
||||
|
||||
class MakerWorldUrlError(ProviderUrlError, MakerWorldError):
|
||||
"""Raised when a URL isn't a makerworld.com model page."""
|
||||
@@ -0,0 +1,166 @@
|
||||
"""MakerWorld HTTP layer.
|
||||
|
||||
Constants and the low-level transport helpers for the MakerWorld / Bambu Lab
|
||||
APIs: the S3 presigned-download path that must reach the transport
|
||||
byte-for-byte, upstream error extraction, and the CDN SSRF guard helpers used
|
||||
by :class:`MakerWorldService`.
|
||||
|
||||
The app-scoped shared ``httpx`` client lives with its consumer instead (see
|
||||
``service.set_shared_http_client``) — same-module so the service reads the
|
||||
live value, matching ``bambu_cloud`` / ``orca_cloud`` / ``slicer_api``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
from backend.app.services.model_providers.makerworld.errors import MakerWorldUnavailableError
|
||||
|
||||
# API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
|
||||
# backend that the MakerWorld web UI talks to, but not behind Cloudflare
|
||||
# (the website ``makerworld.com`` is, and plain httpx requests there get
|
||||
# fingerprinted as bot traffic and served "Please log in").
|
||||
MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
|
||||
|
||||
# Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3 presigned
|
||||
# URLs (e.g. ``s3.us-west-2.amazonaws.com``) from the iot-service download
|
||||
# endpoint. The suffix check matches any regional S3 endpoint.
|
||||
#
|
||||
# Deliberately NOT part of the ``download_hosts()`` seam: that seam is an
|
||||
# exact-host allowlist a provider declares, and this is a suffix family
|
||||
# belonging to Bambu's signed-URL infrastructure specifically. It stays a
|
||||
# constant of *this* provider's transport: ``download_3mf`` accepts the
|
||||
# injected hosts or an S3 endpoint, while a second provider brings its own
|
||||
# service and declares its own ``download_hosts()``.
|
||||
_ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
|
||||
|
||||
# The shared default SSRF allowlist for MakerWorld CDN traffic. The thumbnail
|
||||
# proxy and the 3MF download path are both driven by the provider descriptor
|
||||
# instead — ``build_service`` feeds the runner's ``ModelProvider.thumbnail_hosts()``
|
||||
# and ``download_hosts()`` into ``MakerWorldService`` — and this tuple is what
|
||||
# those methods return by default. Lives here with the other transport guards
|
||||
# so the allowlist is in one place.
|
||||
MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
|
||||
|
||||
# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
|
||||
# as Bambuddy with a source URL so Bambu can distinguish our traffic from
|
||||
# impersonators — the opposite of what the OrcaSlicer fork was called out for
|
||||
# in the May 2026 Bambu Lab blog post on cloud access. The Referer is kept
|
||||
# because MakerWorld's CSRF / origin-check middleware uses it on some
|
||||
# endpoints — that's distinct from client impersonation.
|
||||
_CLIENT_HEADERS = {
|
||||
"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://makerworld.com/",
|
||||
}
|
||||
|
||||
_MAX_3MF_BYTES = 200 * 1024 * 1024 # 200 MB hard cap
|
||||
_MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024 # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
|
||||
|
||||
_IMAGE_EXT_TO_MIME = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
# Content types we refuse even if the URL extension looks image-y — prevents
|
||||
# forwarding an upstream error page or JSON blob with image framing.
|
||||
_REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
|
||||
|
||||
|
||||
def _s3_ssl_context() -> ssl.SSLContext:
|
||||
"""Build the TLS context used for the S3 presigned download (#2562).
|
||||
|
||||
``urllib.request`` verifies against the *OS* trust store, while httpx —
|
||||
every other network call in Bambuddy — verifies against the bundled
|
||||
``certifi`` CA bundle. On Windows those two disagree: Python's
|
||||
``ssl.load_default_certs()`` only enumerates the roots already cached in
|
||||
the Windows ROOT store, and Windows populates that store lazily via
|
||||
CryptoAPI's auto-update, which Python never triggers. If the Amazon root
|
||||
signing the S3 chain isn't cached on that machine yet, verification fails
|
||||
with ``unable to get local issuer certificate`` — even though the
|
||||
api.bambulab.com calls that preceded it (httpx) succeeded.
|
||||
|
||||
Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
|
||||
the app already trusts. Built per call rather than at import so a certifi
|
||||
refresh doesn't require a restart; construction is cheap relative to the
|
||||
download that follows.
|
||||
"""
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
|
||||
async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
|
||||
"""Fetch an AWS S3 presigned URL without touching the query string.
|
||||
|
||||
``urllib.request`` passes the URL to the transport verbatim — which is
|
||||
essential for S3 presigned URLs where the signature is computed over
|
||||
the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
|
||||
libcurl layer both normalise encodings and produce
|
||||
``SignatureDoesNotMatch`` 400s from S3.
|
||||
|
||||
Runs the blocking urllib call in a thread executor so we don't stall
|
||||
the event loop.
|
||||
"""
|
||||
from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
|
||||
|
||||
# Don't follow redirects: the host allowlist is only enforced on
|
||||
# the initial URL. A 302 from S3 to any other host would otherwise
|
||||
# transparently bypass the allowlist — so insist S3 resolve directly.
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs): # type: ignore[override]
|
||||
return None
|
||||
|
||||
# HTTPSHandler swaps only the TLS context — the URL still reaches the
|
||||
# transport verbatim, which is what the S3 signature depends on.
|
||||
opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
|
||||
|
||||
def _blocking_fetch() -> bytes:
|
||||
req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
|
||||
with opener.open(req, timeout=60.0) as resp:
|
||||
if resp.status != 200:
|
||||
raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
|
||||
data = b""
|
||||
while True:
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if len(data) > _MAX_3MF_BYTES:
|
||||
raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
|
||||
return data
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(_blocking_fetch)
|
||||
except MakerWorldUnavailableError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — urllib throws a zoo of exceptions
|
||||
raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
|
||||
return data, filename_fallback
|
||||
|
||||
|
||||
def _extract_upstream_error(response: httpx.Response) -> str | None:
|
||||
"""Pull MakerWorld's own error text out of a 4xx/5xx response body.
|
||||
|
||||
MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
|
||||
and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
|
||||
the body isn't JSON or doesn't have a recognised error field — callers
|
||||
should fall back to a generic message in that case.
|
||||
"""
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for key in ("error", "message", "detail"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
@@ -0,0 +1,117 @@
|
||||
"""MakerWorld model provider.
|
||||
|
||||
Static descriptor + per-request service factory for makerworld.com. The
|
||||
``MakerWorldProvider`` instance is what gets registered in the shared
|
||||
:class:`ModelProviderRegistry`; the actual API work lives in ``service.py``
|
||||
(the per-request :class:`ProviderService`) and ``url.py`` (URL parsing and
|
||||
canonicalisation). Credential handling is centralised here so route layers
|
||||
never touch MakerWorld specifics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.services.model_providers.base import (
|
||||
ModelProvider,
|
||||
ProviderAuthConfig,
|
||||
ProviderAuthType,
|
||||
ProviderResourceRef,
|
||||
ProviderService,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld import url as mw_url
|
||||
from backend.app.services.model_providers.makerworld.auth import (
|
||||
get_stored_token,
|
||||
mark_cloud_token_invalid,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld.http import MAKERWORLD_CDN_HOSTS
|
||||
from backend.app.services.model_providers.makerworld.service import MakerWorldService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.user import User
|
||||
|
||||
|
||||
class MakerWorldProvider(ModelProvider):
|
||||
"""MakerWorld descriptor: identity, URL routing, auth requirements, and the
|
||||
factory that builds a per-request :class:`MakerWorldService` seeded with the
|
||||
caller's stored Bambu Cloud bearer token.
|
||||
"""
|
||||
|
||||
source_type = "makerworld"
|
||||
display_name = "MakerWorld"
|
||||
host_patterns = ("makerworld.com",)
|
||||
auth = ProviderAuthConfig(
|
||||
auth_type=ProviderAuthType.BAMBU_CLOUD_BEARER,
|
||||
display_label="Bambu Cloud sign-in",
|
||||
description=(
|
||||
"MakerWorld downloads reuse the Bambu Cloud account already stored in Bambuddy — "
|
||||
"there is no separate MakerWorld sign-in."
|
||||
),
|
||||
setup_hint="Open the Profiles page and sign in to Bambu Cloud.",
|
||||
)
|
||||
default_folder_name = "MakerWorld"
|
||||
view_permission = Permission.MAKERWORLD_VIEW
|
||||
import_permission = Permission.MAKERWORLD_IMPORT
|
||||
|
||||
async def build_service(
|
||||
self,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
api_key_owner: User | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> ProviderService:
|
||||
"""Build a per-request service seeded with the caller's stored Bambu
|
||||
Cloud bearer, mirroring ``cloud.build_authenticated_cloud``.
|
||||
|
||||
``api_key_owner`` is the API key's owning user for API-keyed calls
|
||||
(see ``resolve_api_key_cloud_owner``); MakerWorld uses it as the
|
||||
fallback identity when ``user`` is None. Like the cloud integration, a
|
||||
rejected token is recorded so the whole app agrees the sign-in is dead
|
||||
rather than each feature failing on its own — including auth-disabled
|
||||
single-user installs, where ``user_id=None`` records the *global*
|
||||
flag those installs read back on the status endpoints.
|
||||
"""
|
||||
identity = user if user is not None else api_key_owner
|
||||
token, _email, _region = await get_stored_token(db, identity)
|
||||
user_id = identity.id if identity is not None else None
|
||||
return MakerWorldService(
|
||||
client=client,
|
||||
auth_token=token,
|
||||
user=identity,
|
||||
on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
|
||||
# The SSRF allowlists are the provider's declared seams — the
|
||||
# service must not hardcode its own copies (symmetric pair,
|
||||
# ``fetch_thumbnail`` / ``download``).
|
||||
thumbnail_hosts=self.thumbnail_hosts(),
|
||||
download_hosts=self.download_hosts(),
|
||||
)
|
||||
|
||||
def parse_url(self, url: str) -> ProviderResourceRef:
|
||||
return mw_url.parse_url(url)
|
||||
|
||||
def canonical_url(self, ref: ProviderResourceRef) -> str:
|
||||
return mw_url.canonical_url(ref)
|
||||
|
||||
def source_url_filter(self, column, external_id: str):
|
||||
"""Whole-model key plus every per-plate key — MakerWorld's canonical
|
||||
shape appends ``#profileId-{n}`` for plate-level dedupe (see
|
||||
``url.canonical_url``), so the already-imported detection must match
|
||||
both. The ``#profileId-`` fragment lives here with the descriptor
|
||||
because it is part of this provider's URL contract."""
|
||||
prefix = mw_url.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
|
||||
return (column == prefix) | (column.like(f"{prefix}#profileId-%"))
|
||||
|
||||
def thumbnail_hosts(self) -> tuple[str, ...]:
|
||||
return MAKERWORLD_CDN_HOSTS
|
||||
|
||||
def download_hosts(self) -> tuple[str, ...]:
|
||||
return MAKERWORLD_CDN_HOSTS
|
||||
|
||||
|
||||
makerworld_provider = MakerWorldProvider()
|
||||
+186
-229
@@ -9,7 +9,11 @@ The endpoints and header set were reverse-engineered from the
|
||||
`kloshi-io/makerworld-api-reverse` TypeScript project (Apache-2.0) and
|
||||
cross-validated against live MakerWorld traffic. Authenticated calls reuse
|
||||
Bambuddy's existing Bambu Cloud bearer token (same SSO backend — no separate
|
||||
OAuth flow needed).
|
||||
OAuth flow needed; see ``model_providers/makerworld/auth.py``).
|
||||
|
||||
Implements the :class:`ProviderService` interface — the route layer drives it
|
||||
through ``resolve`` / ``get_download`` / ``download`` so the same flow can be
|
||||
reused for future providers.
|
||||
|
||||
Only interoperability — not affiliated with or endorsed by MakerWorld or
|
||||
Bambu Lab, and not intended to circumvent any access control.
|
||||
@@ -19,49 +23,59 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
|
||||
from backend.app.services.model_providers.base import (
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
ProviderService,
|
||||
ProviderStatus,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld.auth import is_cloud_token_invalid
|
||||
from backend.app.services.model_providers.makerworld.errors import (
|
||||
MakerWorldAuthError,
|
||||
MakerWorldForbiddenError,
|
||||
MakerWorldNotFoundError,
|
||||
MakerWorldUnavailableError,
|
||||
MakerWorldUrlError,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld.http import (
|
||||
_ALLOWED_DOWNLOAD_SUFFIXES,
|
||||
_CLIENT_HEADERS,
|
||||
_IMAGE_EXT_TO_MIME,
|
||||
_MAX_3MF_BYTES,
|
||||
_MAX_THUMBNAIL_BYTES,
|
||||
_REFUSED_THUMBNAIL_MIMES,
|
||||
MAKERWORLD_API_BASE,
|
||||
MAKERWORLD_CDN_HOSTS,
|
||||
_download_s3_urllib,
|
||||
_extract_upstream_error,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_shared_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
# API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
|
||||
# backend that the MakerWorld web UI talks to, but not behind Cloudflare
|
||||
# (the website ``makerworld.com`` is, and plain httpx requests there get
|
||||
# fingerprinted as bot traffic and served "Please log in"). Confirmed by
|
||||
# Pr0zak/YASTL#51 and verified with direct curl.
|
||||
MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
|
||||
MAKERWORLD_HOST = "makerworld.com" # Used only for URL parsing (input validation)
|
||||
MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
|
||||
|
||||
# Hosts that the iot-service download endpoint may return presigned URLs
|
||||
# for. Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3
|
||||
# presigned URLs (e.g. ``s3.us-west-2.amazonaws.com``) — confirmed by
|
||||
# Pr0zak/YASTL#52. The suffix check matches any regional S3 endpoint.
|
||||
_ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
|
||||
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
|
||||
"""Register an app-scoped ``httpx.AsyncClient`` for service reuse.
|
||||
|
||||
Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
|
||||
lifespan share one connection pool across per-request service instances.
|
||||
Must live in the same module as the service class so ``__init__`` reads
|
||||
the live value rather than an import-time snapshot.
|
||||
"""
|
||||
global _shared_http_client
|
||||
_shared_http_client = client
|
||||
|
||||
# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
|
||||
# as Bambuddy with a source URL so Bambu can distinguish our traffic from
|
||||
# impersonators — the opposite of what the OrcaSlicer fork was called out for
|
||||
# in the May 2026 Bambu Lab blog post on cloud access. Verified 2026-05-12 via
|
||||
# curl that MakerWorld treats this UA identically to a Firefox UA at the
|
||||
# Cloudflare edge (same response shape on /api/v1/design-service/* paths).
|
||||
# The Referer is kept because MakerWorld's CSRF / origin-check middleware uses
|
||||
# it on some endpoints — that's distinct from client impersonation.
|
||||
_CLIENT_HEADERS = {
|
||||
"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://makerworld.com/",
|
||||
}
|
||||
|
||||
# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
|
||||
# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
|
||||
@@ -75,155 +89,8 @@ _SIGN_IN_EXPIRED_MESSAGE = (
|
||||
"Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
|
||||
)
|
||||
|
||||
_MODEL_ID_RE = re.compile(r"/models/(\d+)")
|
||||
_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
|
||||
_MAX_3MF_BYTES = 200 * 1024 * 1024 # 200 MB hard cap
|
||||
_MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024 # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
|
||||
_IMAGE_EXT_TO_MIME = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
# Content types we refuse even if the URL extension looks image-y — prevents
|
||||
# forwarding an upstream error page or JSON blob with image framing.
|
||||
_REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
|
||||
|
||||
_shared_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _s3_ssl_context() -> ssl.SSLContext:
|
||||
"""Build the TLS context used for the S3 presigned download (#2562).
|
||||
|
||||
``urllib.request`` verifies against the *OS* trust store, while httpx —
|
||||
every other network call in Bambuddy — verifies against the bundled
|
||||
``certifi`` CA bundle. On Windows those two disagree: Python's
|
||||
``ssl.load_default_certs()`` only enumerates the roots already cached in
|
||||
the Windows ROOT store, and Windows populates that store lazily via
|
||||
CryptoAPI's auto-update, which Python never triggers. If the Amazon root
|
||||
signing the S3 chain isn't cached on that machine yet, verification fails
|
||||
with ``unable to get local issuer certificate`` — even though the
|
||||
api.bambulab.com calls that preceded it (httpx) succeeded.
|
||||
|
||||
Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
|
||||
the app already trusts. Built per call rather than at import so a certifi
|
||||
refresh doesn't require a restart; construction is cheap relative to the
|
||||
download that follows.
|
||||
"""
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
|
||||
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
|
||||
"""Register an app-scoped ``httpx.AsyncClient`` for service reuse.
|
||||
|
||||
Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
|
||||
lifespan share one connection pool across per-request service instances.
|
||||
"""
|
||||
global _shared_http_client
|
||||
_shared_http_client = client
|
||||
|
||||
|
||||
class MakerWorldError(Exception):
|
||||
"""Base exception for MakerWorld API errors."""
|
||||
|
||||
|
||||
class MakerWorldAuthError(MakerWorldError):
|
||||
"""Raised when the endpoint requires a Bambu Cloud token and we don't have
|
||||
one (or the one we sent was rejected). True auth failure."""
|
||||
|
||||
|
||||
class MakerWorldForbiddenError(MakerWorldError):
|
||||
"""Raised when MakerWorld refuses access despite valid authentication —
|
||||
content-gated (points required, purchase required, region restricted,
|
||||
early-access, etc.). The message includes MakerWorld's own reason text
|
||||
when provided."""
|
||||
|
||||
|
||||
class MakerWorldNotFoundError(MakerWorldError):
|
||||
"""Raised when a design / profile / instance doesn't exist."""
|
||||
|
||||
|
||||
class MakerWorldUnavailableError(MakerWorldError):
|
||||
"""Raised on 5xx, network errors, or malformed payloads."""
|
||||
|
||||
|
||||
class MakerWorldUrlError(MakerWorldError):
|
||||
"""Raised when a URL isn't a makerworld.com model page."""
|
||||
|
||||
|
||||
async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
|
||||
"""Fetch an AWS S3 presigned URL without touching the query string.
|
||||
|
||||
``urllib.request`` passes the URL to the transport verbatim — which is
|
||||
essential for S3 presigned URLs where the signature is computed over
|
||||
the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
|
||||
libcurl layer both normalise encodings and produce
|
||||
``SignatureDoesNotMatch`` 400s from S3.
|
||||
|
||||
Runs the blocking urllib call in a thread executor so we don't stall
|
||||
the event loop.
|
||||
"""
|
||||
from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
|
||||
|
||||
# Don't follow redirects: the host allowlist above is only enforced on
|
||||
# the initial URL. A 302 from S3 to any other host would otherwise
|
||||
# transparently bypass the allowlist — so insist S3 resolve directly.
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs): # type: ignore[override]
|
||||
return None
|
||||
|
||||
# HTTPSHandler swaps only the TLS context — the URL still reaches the
|
||||
# transport verbatim, which is what the S3 signature depends on.
|
||||
opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
|
||||
|
||||
def _blocking_fetch() -> bytes:
|
||||
req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
|
||||
with opener.open(req, timeout=60.0) as resp:
|
||||
if resp.status != 200:
|
||||
raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
|
||||
data = b""
|
||||
while True:
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if len(data) > _MAX_3MF_BYTES:
|
||||
raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
|
||||
return data
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(_blocking_fetch)
|
||||
except MakerWorldUnavailableError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — urllib throws a zoo of exceptions
|
||||
raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
|
||||
return data, filename_fallback
|
||||
|
||||
|
||||
def _extract_upstream_error(response: httpx.Response) -> str | None:
|
||||
"""Pull MakerWorld's own error text out of a 4xx/5xx response body.
|
||||
|
||||
MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
|
||||
and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
|
||||
the body isn't JSON or doesn't have a recognised error field — callers
|
||||
should fall back to a generic message in that case.
|
||||
"""
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for key in ("error", "message", "detail"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
class MakerWorldService:
|
||||
class MakerWorldService(ProviderService):
|
||||
"""Per-request MakerWorld API client.
|
||||
|
||||
Mirrors ``BambuCloudService``'s construction pattern so callers can
|
||||
@@ -233,15 +100,26 @@ class MakerWorldService:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
auth_token: str | None = None,
|
||||
user: Any | None = None,
|
||||
on_auth_failure: Callable[[], Awaitable[None]] | None = None,
|
||||
thumbnail_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
|
||||
download_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
|
||||
):
|
||||
# Fired when Bambu rejects the stored token (401). MakerWorld runs on the
|
||||
# same Bambu Cloud bearer as everything else, so a rejection here means
|
||||
# the credential is dead app-wide — see ``build_authenticated_cloud``.
|
||||
self._on_auth_failure = on_auth_failure
|
||||
self._auth_failure_reported = False
|
||||
# SSRF allowlists for the thumbnail proxy and the 3MF download guard.
|
||||
# Default to MakerWorld's CDN hosts; ``MakerWorldProvider.build_service``
|
||||
# passes ``ModelProvider.thumbnail_hosts()`` / ``download_hosts()`` so
|
||||
# the guards are driven by the provider descriptor rather than enforced
|
||||
# by coincidence (interface contract on ``ProviderService``).
|
||||
self._thumbnail_hosts = tuple(thumbnail_hosts)
|
||||
self._download_hosts = tuple(download_hosts)
|
||||
if client is not None:
|
||||
self._client = client
|
||||
self._owns_client = False
|
||||
@@ -252,6 +130,7 @@ class MakerWorldService:
|
||||
self._client = httpx.AsyncClient(timeout=30.0)
|
||||
self._owns_client = True
|
||||
self._auth_token = auth_token
|
||||
self._user = user
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._owns_client:
|
||||
@@ -283,6 +162,121 @@ class MakerWorldService:
|
||||
headers["Authorization"] = f"Bearer {self._auth_token}"
|
||||
return headers
|
||||
|
||||
# ------------------------------------------------------------- interface
|
||||
|
||||
async def get_status(self, db: Any) -> ProviderStatus:
|
||||
"""Whether the caller can download: needs a stored, non-rejected Bambu
|
||||
Cloud token. ``credential_rejected`` is the machine-readable expired
|
||||
state; ``auth_error`` names it for humans so the UI can say "your
|
||||
sign-in expired" rather than a bare "sign in"."""
|
||||
has_token = bool(self._auth_token)
|
||||
expired = has_token and await is_cloud_token_invalid(db, self._user)
|
||||
return ProviderStatus(
|
||||
authenticated=has_token,
|
||||
can_download=has_token and not expired,
|
||||
auth_error=_SIGN_IN_EXPIRED_MESSAGE if expired else None,
|
||||
credential_rejected=expired,
|
||||
)
|
||||
|
||||
async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
|
||||
"""Fetch full model metadata + the plate list, merging per-instance
|
||||
printer compatibility so the frontend can show "sliced for A1 / also
|
||||
compatible with H2D, P1S" before the user picks a plate."""
|
||||
model_id = int(ref.external_id)
|
||||
design = await self.get_design(model_id)
|
||||
instances_envelope = await self.get_design_instances(model_id)
|
||||
|
||||
# MakerWorld's instances payload is ``{"total": N, "hits": [...]}``;
|
||||
# normalise the null case to an empty list so the frontend doesn't
|
||||
# have to handle null vs [] both ways.
|
||||
instances = instances_envelope.get("hits") or []
|
||||
if not isinstance(instances, list):
|
||||
instances = []
|
||||
|
||||
# /instances/hits omits the per-instance printer compatibility info
|
||||
# that /design.instances[].extention.modelInfo carries. Merge it in.
|
||||
design_instances = design.get("instances") or []
|
||||
if isinstance(design_instances, list):
|
||||
compat_by_id = {}
|
||||
for di in design_instances:
|
||||
if not isinstance(di, dict):
|
||||
continue
|
||||
iid = di.get("id")
|
||||
if iid is None:
|
||||
continue
|
||||
ext = (di.get("extention") or {}).get("modelInfo") or {}
|
||||
compat_by_id[iid] = {
|
||||
"compatibility": ext.get("compatibility"),
|
||||
"otherCompatibility": ext.get("otherCompatibility"),
|
||||
}
|
||||
for inst in instances:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
iid = inst.get("id")
|
||||
extra = compat_by_id.get(iid)
|
||||
if extra:
|
||||
inst["compatibility"] = extra["compatibility"]
|
||||
inst["otherCompatibility"] = extra["otherCompatibility"]
|
||||
|
||||
return ProviderResolvedModel(ref=ref, design=design, instances=instances)
|
||||
|
||||
async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
|
||||
"""Resolve the signed 3MF download for a specific MakerWorld profile.
|
||||
|
||||
Handles the provider-specific dance: the iot-service endpoint needs
|
||||
the *alphanumeric* ``modelId`` (e.g. ``"US2bb73b106683e5"``) from the
|
||||
design, not the integer design id, and picks a default profile when
|
||||
the caller didn't specify one. Enriches ``ref.sub_id`` with the actual
|
||||
profile used so the route can build the per-plate dedupe key.
|
||||
"""
|
||||
model_id = int(ref.external_id)
|
||||
design = await self.get_design(model_id)
|
||||
|
||||
alphanumeric_model_id = design.get("modelId")
|
||||
if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
|
||||
raise MakerWorldUnavailableError("MakerWorld design metadata missing the modelId field")
|
||||
|
||||
profile_id = int(ref.sub_id) if ref.sub_id else None
|
||||
if profile_id is None:
|
||||
for instance in design.get("instances") or []:
|
||||
pid = instance.get("profileId")
|
||||
if isinstance(pid, int) and pid > 0:
|
||||
profile_id = pid
|
||||
break
|
||||
if profile_id is None:
|
||||
envelope = await self.get_design_instances(model_id)
|
||||
for hit in envelope.get("hits") or []:
|
||||
pid = hit.get("profileId")
|
||||
if isinstance(pid, int) and pid > 0:
|
||||
profile_id = pid
|
||||
break
|
||||
if profile_id is None:
|
||||
raise MakerWorldUnavailableError("MakerWorld returned no instances for this model")
|
||||
|
||||
manifest = await self.get_profile_download(profile_id, alphanumeric_model_id)
|
||||
|
||||
signed_url = manifest.get("url")
|
||||
if not signed_url or not isinstance(signed_url, str):
|
||||
raise MakerWorldUnavailableError("MakerWorld did not return a download URL")
|
||||
|
||||
# Raw upstream name — the route layer basenames / percent-decodes it
|
||||
# as defence-in-depth before persisting.
|
||||
raw_name = manifest.get("name")
|
||||
suggested_filename = raw_name if isinstance(raw_name, str) and raw_name.strip() else ""
|
||||
|
||||
return ProviderDownloadInfo(
|
||||
ref=replace(ref, sub_id=str(profile_id)),
|
||||
url=signed_url,
|
||||
suggested_filename=suggested_filename,
|
||||
)
|
||||
|
||||
async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
|
||||
"""Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
|
||||
file_bytes, download_filename = await self.download_3mf(info.url)
|
||||
return ProviderDownload(file_bytes=file_bytes, filename=download_filename)
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
async def _get_json(self, path: str) -> dict[str, Any]:
|
||||
"""GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
|
||||
|
||||
@@ -376,49 +370,6 @@ class MakerWorldService:
|
||||
)
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------ URL parse
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url: str) -> tuple[int, int | None]:
|
||||
"""Extract ``(model_id, profile_id_or_None)`` from a MakerWorld URL.
|
||||
|
||||
Accepts any of:
|
||||
- ``https://makerworld.com/en/models/1400373``
|
||||
- ``https://makerworld.com/en/models/1400373-slug-with-dashes``
|
||||
- ``https://makerworld.com/en/models/1400373#profileId-1452154``
|
||||
- ``makerworld.com/models/1400373`` (scheme optional)
|
||||
|
||||
Rejects non-makerworld hosts.
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
raise MakerWorldUrlError("URL is empty or not a string")
|
||||
candidate = url.strip()
|
||||
if "://" not in candidate:
|
||||
candidate = "https://" + candidate
|
||||
try:
|
||||
parsed = urlparse(candidate)
|
||||
except ValueError as exc:
|
||||
raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
|
||||
raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
|
||||
|
||||
model_match = _MODEL_ID_RE.search(parsed.path)
|
||||
if not model_match:
|
||||
raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
|
||||
model_id = int(model_match.group(1))
|
||||
|
||||
profile_id: int | None = None
|
||||
if parsed.fragment:
|
||||
profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
|
||||
if profile_match:
|
||||
profile_id = int(profile_match.group(1))
|
||||
|
||||
return model_id, profile_id
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
async def get_design(self, model_id: int) -> dict[str, Any]:
|
||||
"""Fetch full model metadata. Works anonymously.
|
||||
|
||||
@@ -510,8 +461,13 @@ class MakerWorldService:
|
||||
async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
|
||||
"""Fetch the 3MF bytes from a signed MakerWorld CDN URL.
|
||||
|
||||
Validates that the URL's host is one of the known MakerWorld CDN hosts
|
||||
(SSRF guard — pattern matches ``_spoolman_helpers.assert_safe_spoolman_url``).
|
||||
Validates that the URL's host is one of the declared download hosts
|
||||
(SSRF guard — driven by ``ModelProvider.download_hosts()`` via
|
||||
``build_service``, the symmetric counterpart to the thumbnail
|
||||
allowlist) *or* matches ``_ALLOWED_DOWNLOAD_SUFFIXES``, Bambu's S3
|
||||
regional endpoints, which are this provider's own signed-URL family
|
||||
rather than part of the injectable seam; pattern matches
|
||||
``_spoolman_helpers.assert_safe_spoolman_url``.
|
||||
Enforces a 200 MB cap so a single bad response can't exhaust disk.
|
||||
|
||||
Returns ``(file_bytes, suggested_filename)``.
|
||||
@@ -522,7 +478,7 @@ class MakerWorldService:
|
||||
raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
is_allowed = host in MAKERWORLD_CDN_HOSTS or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
|
||||
is_allowed = host in self._download_hosts or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
|
||||
if not is_allowed:
|
||||
raise MakerWorldUrlError(f"Refusing to download from non-MakerWorld host: {host!r}")
|
||||
|
||||
@@ -570,9 +526,10 @@ class MakerWorldService:
|
||||
SPA's ``img-src`` CSP and keeps users' IP addresses out of
|
||||
MakerWorld's access logs.
|
||||
|
||||
Validates that the URL's host is one of the known MakerWorld CDN
|
||||
hosts (SSRF guard — same allowlist as :meth:`download_3mf`). Caps
|
||||
payload at 5 MB. Returns ``(bytes, content_type)``; content type
|
||||
Validates that the URL's host is one of the declared thumbnail hosts
|
||||
(SSRF guard — symmetric to :meth:`download_3mf`; both allowlists are
|
||||
fed from the provider descriptor by ``build_service``). Caps
|
||||
payload at 10 MB. Returns ``(bytes, content_type)``; content type
|
||||
defaults to ``image/jpeg`` if the upstream didn't set one.
|
||||
"""
|
||||
try:
|
||||
@@ -581,7 +538,7 @@ class MakerWorldService:
|
||||
raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
if host not in MAKERWORLD_CDN_HOSTS:
|
||||
if host not in self._thumbnail_hosts:
|
||||
raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
|
||||
|
||||
# ``follow_redirects=False``: the host allowlist above is only
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MakerWorld URL parsing and canonicalisation.
|
||||
|
||||
Extracts ``(model_id, profile_id_or_None)`` from model URLs and builds the
|
||||
stable dedupe key used as the library ``source_url``. Rejects non-makerworld
|
||||
hosts — this is the input-validation surface, so it is deliberately strict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from backend.app.services.model_providers.base import ProviderResourceRef
|
||||
from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
|
||||
|
||||
MAKERWORLD_HOST = "makerworld.com" # Used only for URL parsing (input validation)
|
||||
|
||||
_MODEL_ID_RE = re.compile(r"/models/(\d+)")
|
||||
_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
|
||||
|
||||
|
||||
def parse_url(url: str) -> ProviderResourceRef:
|
||||
"""Extract a :class:`ProviderResourceRef` from a MakerWorld URL.
|
||||
|
||||
Accepts any of:
|
||||
- ``https://makerworld.com/en/models/1400373``
|
||||
- ``https://makerworld.com/en/models/1400373-slug-with-dashes``
|
||||
- ``https://makerworld.com/en/models/1400373#profileId-1452154``
|
||||
- ``makerworld.com/models/1400373`` (scheme optional)
|
||||
|
||||
Rejects non-makerworld hosts.
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
raise MakerWorldUrlError("URL is empty or not a string")
|
||||
candidate = url.strip()
|
||||
if "://" not in candidate:
|
||||
candidate = "https://" + candidate
|
||||
try:
|
||||
parsed = urlparse(candidate)
|
||||
except ValueError as exc:
|
||||
raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
|
||||
raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
|
||||
|
||||
model_match = _MODEL_ID_RE.search(parsed.path)
|
||||
if not model_match:
|
||||
raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
|
||||
model_id = int(model_match.group(1))
|
||||
|
||||
profile_id: int | None = None
|
||||
if parsed.fragment:
|
||||
profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
|
||||
if profile_match:
|
||||
profile_id = int(profile_match.group(1))
|
||||
|
||||
return ProviderResourceRef(
|
||||
source_type="makerworld",
|
||||
external_id=str(model_id),
|
||||
sub_id=str(profile_id) if profile_id is not None else None,
|
||||
original_url=url,
|
||||
)
|
||||
|
||||
|
||||
def canonical_url(ref: ProviderResourceRef) -> str:
|
||||
"""Build a stable dedupe key for a MakerWorld resource.
|
||||
|
||||
Dedupe is keyed per *plate* (profile) rather than per model, since the
|
||||
download returns a specific plate — not the full multi-plate zip — so two
|
||||
different plates of the same design should become two separate library
|
||||
entries. Canonical shape uses the locale-free path with the
|
||||
``#profileId-`` fragment so all URL variants of the same plate still
|
||||
collapse (e.g. ``/en/models/123-slug?from=search#profileId-456`` and
|
||||
``/de/models/123#profileId-456`` both map to
|
||||
``https://makerworld.com/models/123#profileId-456``). Plate-less imports
|
||||
(legacy or whole-design) keep the old model-only shape for backwards
|
||||
compatibility with existing rows.
|
||||
"""
|
||||
if ref.sub_id:
|
||||
return f"https://makerworld.com/models/{ref.external_id}#profileId-{ref.sub_id}"
|
||||
return f"https://makerworld.com/models/{ref.external_id}"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Provider registry — maps ``source_type`` / URLs to model providers.
|
||||
|
||||
The registry is the routing layer a future *shared* import API uses: a pasted
|
||||
URL goes through :meth:`ModelProviderRegistry.find_for_url`, which asks each
|
||||
registered provider ``supports_url`` and returns the one that owns it. Today
|
||||
the route layer still calls the MakerWorld provider directly (endpoints stay
|
||||
at ``/makerworld/*``), but registering providers here keeps the seam ready.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.services.model_providers.base import ModelProvider
|
||||
|
||||
|
||||
class ModelProviderRegistry:
|
||||
"""Holds the registered :class:`ModelProvider` instances.
|
||||
|
||||
Registering is idempotent per provider instance; registering a *different*
|
||||
provider under an already-taken ``source_type`` is an error.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._providers: dict[str, ModelProvider] = {}
|
||||
|
||||
def register(self, provider: ModelProvider) -> None:
|
||||
existing = self._providers.get(provider.source_type)
|
||||
if existing is not None and existing is not provider:
|
||||
raise ValueError(f"A model provider for source_type {provider.source_type!r} is already registered")
|
||||
self._providers[provider.source_type] = provider
|
||||
|
||||
def get(self, source_type: str) -> ModelProvider:
|
||||
try:
|
||||
return self._providers[source_type]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"No model provider registered for source_type {source_type!r}") from exc
|
||||
|
||||
def all(self) -> tuple[ModelProvider, ...]:
|
||||
return tuple(self._providers.values())
|
||||
|
||||
def find_for_url(self, url: str) -> ModelProvider | None:
|
||||
"""Return the provider that claims ``url``, or ``None`` if none do.
|
||||
|
||||
Iterates in registration (dict insertion) order; when more than one
|
||||
provider ``supports_url`` the *first registered* one wins. Providers
|
||||
overlap rarely (``host_patterns`` are usually disjoint), so this
|
||||
tie-break is documented rather than policed — a "generic" provider
|
||||
must register after the specific ones it might shadow.
|
||||
"""
|
||||
for provider in self._providers.values():
|
||||
if provider.supports_url(url):
|
||||
return provider
|
||||
return None
|
||||
|
||||
|
||||
# App-wide registry. Providers register themselves on package import (see
|
||||
# ``backend/app/services/model_providers/__init__.py``).
|
||||
registry = ModelProviderRegistry()
|
||||
@@ -15,6 +15,8 @@ from typing import Any
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from backend.app.utils.paho_teardown import retire_paho_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -200,7 +202,7 @@ class MQTTRelayService:
|
||||
self._disconnection_event = threading.Event()
|
||||
self.client.disconnect()
|
||||
await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
|
||||
self.client.loop_stop()
|
||||
retire_paho_client(self.client, "relay")
|
||||
except Exception as e:
|
||||
logger.debug("MQTT disconnect error (ignored): %s", e)
|
||||
finally:
|
||||
|
||||
@@ -13,6 +13,8 @@ from typing import Any
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from backend.app.utils.paho_teardown import retire_paho_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -482,7 +484,7 @@ class MQTTSmartPlugService:
|
||||
self._disconnection_event = threading.Event()
|
||||
self.client.disconnect()
|
||||
await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
|
||||
self.client.loop_stop()
|
||||
retire_paho_client(self.client, "smart-plugs")
|
||||
except Exception as e:
|
||||
logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
|
||||
finally:
|
||||
|
||||
@@ -26,8 +26,8 @@ def _is_excluded(name: str) -> bool:
|
||||
return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)
|
||||
|
||||
|
||||
def _get_network_interfaces_psutil() -> list[dict]:
|
||||
"""Non-Linux path (Windows, macOS, BSD): enumerate interfaces via psutil.
|
||||
def _psutil_ipv4_entries(exclude_by_name: bool = False) -> list[dict]:
|
||||
"""Every bindable IPv4 address psutil reports, one entry per address.
|
||||
|
||||
The ioctl request numbers in the Linux path (SIOCGIFADDR 0x8915,
|
||||
SIOCGIFNETMASK 0x891B) and the sockaddr layout they return are
|
||||
@@ -38,10 +38,22 @@ def _get_network_interfaces_psutil() -> list[dict]:
|
||||
(``psutil>=6.0.0``) and gives cross-platform name + IPv4 + netmask in one
|
||||
call, so we use it for everything that isn't Linux.
|
||||
|
||||
Secondary addresses are included. psutil returns every unicast address
|
||||
bound to an adapter, so a Windows host with three IPs on one NIC offers
|
||||
three bind targets rather than one (#3121) — the same thing iproute2 gives
|
||||
Linux. ``is_alias`` marks every address after an interface's first, which
|
||||
is the closest Windows equivalent of an iproute2 alias label.
|
||||
|
||||
Filters: IPv4 only (matches the Linux path), skip loopback and
|
||||
link-local (169.254.0.0/16), skip interfaces psutil reports as down.
|
||||
No name-based exclusion — users may legitimately want to bind a VP to a
|
||||
Hyper-V / WSL / Tailscale / utun virtual adapter.
|
||||
|
||||
Args:
|
||||
exclude_by_name: apply ``EXCLUDED_INTERFACE_PREFIXES``. Only ever true
|
||||
on Linux — those are Linux device names, and a Windows adapter
|
||||
named "Local Area Connection" would match the ``lo`` prefix. The
|
||||
address-class filters above cover the equivalent ground elsewhere,
|
||||
and users may legitimately want to bind a VP to a Hyper-V / WSL /
|
||||
Tailscale / utun adapter.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
@@ -49,7 +61,7 @@ def _get_network_interfaces_psutil() -> list[dict]:
|
||||
logger.warning("psutil not available, interface detection unavailable on this platform")
|
||||
return []
|
||||
|
||||
interfaces = []
|
||||
entries = []
|
||||
try:
|
||||
addrs_by_iface = psutil.net_if_addrs()
|
||||
stats_by_iface = psutil.net_if_stats()
|
||||
@@ -58,10 +70,14 @@ def _get_network_interfaces_psutil() -> list[dict]:
|
||||
return []
|
||||
|
||||
for name, addrs in addrs_by_iface.items():
|
||||
if exclude_by_name and _is_excluded(name):
|
||||
continue
|
||||
|
||||
stats = stats_by_iface.get(name)
|
||||
if stats is not None and not stats.isup:
|
||||
continue
|
||||
|
||||
ipv4_count = 0
|
||||
for addr in addrs:
|
||||
if addr.family != socket.AF_INET:
|
||||
continue
|
||||
@@ -82,25 +98,53 @@ def _get_network_interfaces_psutil() -> list[dict]:
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
interfaces.append(
|
||||
entries.append(
|
||||
{
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"netmask": netmask,
|
||||
"subnet": str(network),
|
||||
# No label to read on this path, so position is all we
|
||||
# have: the first address an adapter reports is its
|
||||
# primary, the rest are secondaries.
|
||||
"is_alias": ipv4_count > 0,
|
||||
"label": name,
|
||||
}
|
||||
)
|
||||
# First IPv4 per interface is enough; matches Linux ioctl which
|
||||
# returns only the primary IP (aliases land via get_all_interface_ips
|
||||
# on Linux, which has no Windows analogue worth replicating).
|
||||
break
|
||||
ipv4_count += 1
|
||||
|
||||
return interfaces
|
||||
return entries
|
||||
|
||||
|
||||
def get_network_interfaces() -> list[dict]:
|
||||
def _get_network_interfaces_psutil() -> list[dict]:
|
||||
"""The primary IPv4 of each interface, in ``get_network_interfaces`` shape.
|
||||
|
||||
That function's callers want one subnet per interface — discovery scan
|
||||
targets, the support bundle — not one entry per alias, so the secondary
|
||||
addresses are dropped here rather than never collected.
|
||||
"""
|
||||
return [
|
||||
{key: entry[key] for key in ("name", "ip", "netmask", "subnet")}
|
||||
for entry in _psutil_ipv4_entries()
|
||||
if not entry["is_alias"]
|
||||
]
|
||||
|
||||
|
||||
def _sort_interface_entries(entries: list[dict]) -> list[dict]:
|
||||
"""Sort in place and return: primary IPs first per interface, then by name."""
|
||||
entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
|
||||
return entries
|
||||
|
||||
|
||||
def get_network_interfaces(include_excluded: bool = False) -> list[dict]:
|
||||
"""Get all network interfaces with their IPs and subnets.
|
||||
|
||||
Args:
|
||||
include_excluded: keep the interfaces ``EXCLUDED_INTERFACE_PREFIXES``
|
||||
normally hides. That list exists to keep docker0 and friends out
|
||||
of the Virtual Printer's bind dropdown; a caller asking about an
|
||||
address the kernel has already chosen needs the real answer.
|
||||
|
||||
Returns:
|
||||
List of dicts with name, ip, netmask, subnet, broadcast
|
||||
"""
|
||||
@@ -121,7 +165,7 @@ def get_network_interfaces() -> list[dict]:
|
||||
name = iface[1]
|
||||
|
||||
# Skip excluded interfaces
|
||||
if _is_excluded(name):
|
||||
if not include_excluded and _is_excluded(name):
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -171,18 +215,25 @@ def get_network_interfaces() -> list[dict]:
|
||||
return interfaces
|
||||
|
||||
|
||||
def get_all_interface_ips() -> list[dict]:
|
||||
"""Get all IPs (primary + aliases) for all non-excluded interfaces.
|
||||
def get_all_interface_ips(include_excluded: bool = False) -> list[dict]:
|
||||
"""Get all IPs (primary + aliases) for every interface, minus the excluded ones.
|
||||
|
||||
Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
|
||||
Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
|
||||
Falls back to :func:`_fallback_get_all_ips` wherever `ip` isn't there to
|
||||
ask — which is every non-Linux host.
|
||||
|
||||
Args:
|
||||
include_excluded: see :func:`get_network_interfaces`.
|
||||
|
||||
Returns:
|
||||
List of dicts with name, ip, netmask, subnet, is_alias, label
|
||||
"""
|
||||
if not _IP_CMD:
|
||||
logger.debug("ip command not found, using ioctl fallback")
|
||||
return _fallback_get_all_ips()
|
||||
# Windows and macOS have no `ip`, so there is nothing to try first. Going
|
||||
# straight to psutil is what lets a Windows NIC carrying three IPs offer
|
||||
# three bind targets instead of one (#3121).
|
||||
if not sys.platform.startswith("linux") or not _IP_CMD:
|
||||
logger.debug("ip command unavailable on this platform, enumerating via psutil")
|
||||
return _fallback_get_all_ips(include_excluded)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -193,17 +244,17 @@ def get_all_interface_ips() -> list[dict]:
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("ip addr show failed: %s", result.stderr)
|
||||
return _fallback_get_all_ips()
|
||||
return _fallback_get_all_ips(include_excluded)
|
||||
|
||||
interfaces_data = json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
|
||||
logger.warning("Failed to run ip -j addr show: %s", e)
|
||||
return _fallback_get_all_ips()
|
||||
return _fallback_get_all_ips(include_excluded)
|
||||
|
||||
entries = []
|
||||
for iface in interfaces_data:
|
||||
ifname = iface.get("ifname", "")
|
||||
if _is_excluded(ifname):
|
||||
if not include_excluded and _is_excluded(ifname):
|
||||
continue
|
||||
|
||||
ipv4_count = 0
|
||||
@@ -236,23 +287,71 @@ def get_all_interface_ips() -> list[dict]:
|
||||
)
|
||||
ipv4_count += 1
|
||||
|
||||
# Sort: primary IPs first per interface, then by interface name
|
||||
entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
|
||||
return entries
|
||||
return _sort_interface_entries(entries)
|
||||
|
||||
|
||||
def _fallback_get_all_ips() -> list[dict]:
|
||||
"""Fallback: wrap get_network_interfaces() result with alias fields."""
|
||||
def _fallback_get_all_ips(include_excluded: bool = False) -> list[dict]:
|
||||
"""Enumerate without iproute2: psutil first, ioctl only if it finds nothing.
|
||||
|
||||
psutil is the better answer because it reports secondary addresses, so a
|
||||
host with no `ip` command still gets one bind target per IP instead of per
|
||||
interface. The ioctl wrap below is what such a host used to get (minus the
|
||||
aliases it never saw) and is kept for the one case psutil can't serve: a
|
||||
hand-rolled venv missing the dependency. It only ever runs on Linux, since
|
||||
the ioctl path returns nothing anywhere else.
|
||||
"""
|
||||
# EXCLUDED_INTERFACE_PREFIXES are Linux device names; see _psutil_ipv4_entries.
|
||||
exclude_by_name = sys.platform.startswith("linux") and not include_excluded
|
||||
entries = _psutil_ipv4_entries(exclude_by_name=exclude_by_name)
|
||||
if entries:
|
||||
# Deliberately not sorted. psutil's adapter order is what this path has
|
||||
# always returned, and find_interface_for_ip() answers with the first
|
||||
# entry whose subnet holds the target -- which the MQTT bridge uses as
|
||||
# the source IP for the #1429 rewrite and the SSDP proxy as its local
|
||||
# interface. Re-ordering it would quietly re-pick those on a host with
|
||||
# two adapters on one subnet. The iproute2 path sorts because it always
|
||||
# has; only Linux sees that order.
|
||||
return entries
|
||||
|
||||
return [
|
||||
{
|
||||
**iface,
|
||||
"is_alias": False,
|
||||
"label": iface["name"],
|
||||
}
|
||||
for iface in get_network_interfaces()
|
||||
for iface in get_network_interfaces(include_excluded)
|
||||
]
|
||||
|
||||
|
||||
def find_local_ipv4_network(local_ip: str) -> ipaddress.IPv4Network | None:
|
||||
"""The IPv4 network configured on the local interface holding ``local_ip``.
|
||||
|
||||
An IPv4 address carries no prefix length, so the only way to know how far
|
||||
a LAN reaches is to read the prefix off the interface that owns the
|
||||
address. ``None`` means no local interface claims it, which is the honest
|
||||
answer whenever the platform gives us no interface data at all.
|
||||
|
||||
Nothing is filtered: ``local_ip`` is an address the kernel already picked
|
||||
as a route source, so answering "unknown" because it happens to sit on a
|
||||
bridge named ``br-something`` would be a worse answer than the truth.
|
||||
"""
|
||||
try:
|
||||
address = ipaddress.IPv4Address(local_ip)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
for iface in get_all_interface_ips(include_excluded=True):
|
||||
if iface.get("ip") != str(address):
|
||||
continue
|
||||
try:
|
||||
return ipaddress.IPv4Network(iface["subnet"], strict=False)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
logger.debug("Interface %s has an unusable subnet %r", iface.get("name"), iface.get("subnet"))
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_interface_for_ip(target_ip: str) -> dict | None:
|
||||
"""Find which interface is on the same subnet as the target IP.
|
||||
|
||||
|
||||
@@ -398,9 +398,18 @@ class NotificationService:
|
||||
# Per-event Priority header (#990). Only set when the user has
|
||||
# explicitly mapped this event to a 1-5 value; otherwise fall through
|
||||
# to the ntfy server's default so existing setups stay unchanged.
|
||||
#
|
||||
# The map is keyed by the provider's toggle column ("on_print_failed"),
|
||||
# because that is what the dialog builds its rows from -- but every
|
||||
# sender is called with the bare event name ("print_failed"), so the
|
||||
# lookup used to miss for every real notification and hit only in tests
|
||||
# that called this method with the prefixed name (issue #3139). Both
|
||||
# spellings are accepted, which also leaves stored configs untouched.
|
||||
event_priorities = config.get("event_priorities") or {}
|
||||
if event_type and isinstance(event_priorities, dict):
|
||||
raw = event_priorities.get(event_type)
|
||||
if raw is None and not event_type.startswith("on_"):
|
||||
raw = event_priorities.get(f"on_{event_type}")
|
||||
try:
|
||||
priority = int(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
|
||||
@@ -25,7 +25,10 @@ from __future__ import annotations
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,11 +45,33 @@ _PLATE_PNG_SMALL_SIZE = 128
|
||||
_BAMBU_GREEN = "#00AE42"
|
||||
_BACKGROUND_COLOR = "#1a1a1a"
|
||||
|
||||
# Above this vertex count, trimesh.simplify_quadric_decimation runs first.
|
||||
# Same cap stl_thumbnail.py uses; matplotlib's Poly3DCollection slows down
|
||||
# nonlinearly past ~100k faces and a plate thumbnail doesn't need detail
|
||||
# beyond what a 512x512 PNG can resolve.
|
||||
_MAX_VERTICES = 100_000
|
||||
# Faces the whole plate is rendered with, every instance counted. Render cost
|
||||
# is faces, not vertices: matplotlib's Poly3DCollection slows down nonlinearly
|
||||
# past ~200k of them, and a 512x512 PNG resolves nothing finer. Roughly what
|
||||
# stl_thumbnail's 100k-vertex cap comes to on a closed mesh.
|
||||
_RENDER_FACE_BUDGET = 200_000
|
||||
|
||||
# A mesh is never decimated below this, however many times it is placed, or a
|
||||
# plate of small parts renders as a field of blobs.
|
||||
_MIN_FACES_PER_MESH = 200
|
||||
|
||||
# Past this many faces after decimation (a plate of thousands of parts, each
|
||||
# already at the floor above) the thumbnail is skipped. It is best-effort, and
|
||||
# the render's memory grows with every face it is handed (#3135).
|
||||
_MAX_PLACED_FACES = 1_000_000
|
||||
|
||||
# Bounds on the object graph: components nest, and a file that references
|
||||
# itself, or places one part a million times, must not be walked forever.
|
||||
_MAX_COMPONENT_DEPTH = 16
|
||||
_MAX_PLACEMENTS = 20_000
|
||||
|
||||
_MODEL_ROOT = "3D/3dmodel.model"
|
||||
|
||||
# One plate render at a time. The slice routes run this off the event loop, and
|
||||
# a render holds the whole placed plate in memory; two slices finishing
|
||||
# together must not hold two. pyplot is NOT what this guards — the renderer
|
||||
# below never touches it (see ``_render_at_size``).
|
||||
_render_lock = threading.Lock()
|
||||
|
||||
# Plate-gcode entries look like ``Metadata/plate_1.gcode``,
|
||||
# ``Metadata/plate_12.gcode`` — anything else is a md5 / json sidecar.
|
||||
@@ -71,7 +96,7 @@ def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
|
||||
missing = _missing_plate_ids(names)
|
||||
if not missing:
|
||||
return threemf_bytes
|
||||
if "3D/3dmodel.model" not in names:
|
||||
if _MODEL_ROOT not in names:
|
||||
logger.debug(
|
||||
"plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
|
||||
sorted(missing),
|
||||
@@ -82,7 +107,8 @@ def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
|
||||
return threemf_bytes
|
||||
|
||||
try:
|
||||
large_png, small_png = _render_model_thumbnails(threemf_bytes)
|
||||
with _render_lock:
|
||||
large_png, small_png = _render_model_thumbnails(threemf_bytes)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"plate_thumbnail: render failed, returning sliced 3MF without injected thumbs: %s",
|
||||
@@ -146,38 +172,15 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
|
||||
|
||||
_configure_matplotlib_cache()
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import trimesh
|
||||
from matplotlib.colors import LightSource
|
||||
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
||||
|
||||
loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
|
||||
if loaded is None or not hasattr(loaded, "vertices") or len(loaded.vertices) == 0:
|
||||
logger.debug("plate_thumbnail: trimesh produced empty mesh from 3MF")
|
||||
with zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as zf:
|
||||
placed = _load_plate_geometry(zf, trimesh, _repair_winding)
|
||||
if placed is None:
|
||||
return None, None
|
||||
|
||||
mesh = loaded
|
||||
if len(mesh.vertices) > _MAX_VERTICES:
|
||||
try:
|
||||
keep_ratio = _MAX_VERTICES / len(mesh.vertices)
|
||||
target_reduction = max(0.01, min(0.99, 1.0 - keep_ratio))
|
||||
mesh = mesh.simplify_quadric_decimation(target_reduction)
|
||||
except Exception as exc:
|
||||
logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
|
||||
|
||||
# Before the vertices are read, not after: ``scaled`` below is indexed by
|
||||
# ``mesh.faces``, so a repair that ever moves a vertex would leave the two
|
||||
# out of step. Shared with stl_thumbnail rather than copied — the reason
|
||||
# these renderers agree is that they run the same code, not similar code.
|
||||
try:
|
||||
_repair_winding(mesh, trimesh, "plate_thumbnail")
|
||||
except Exception as e: # best-effort, as the whole module is
|
||||
logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
|
||||
|
||||
vertices = mesh.vertices
|
||||
vertices, faces = placed
|
||||
bounds_min = vertices.min(axis=0)
|
||||
bounds_max = vertices.max(axis=0)
|
||||
centered = vertices - (bounds_min + bounds_max) / 2
|
||||
@@ -186,7 +189,6 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
|
||||
|
||||
# ndarray, not a list of lists — shading walks this to build normals, and the
|
||||
# list form is ~30x slower to construct. Paid twice per plate: once per size.
|
||||
faces = mesh.faces
|
||||
poly3d = scaled[faces]
|
||||
|
||||
# Resolved once and shared: both sizes must be lit identically or the 128px
|
||||
@@ -195,19 +197,263 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
|
||||
# ``_shade_kwargs``.
|
||||
shade_kw = _shade_kwargs(poly3d, LightSource)
|
||||
|
||||
large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection, shade_kw)
|
||||
small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection, shade_kw)
|
||||
large = _render_at_size(poly3d, _PLATE_PNG_SIZE, Poly3DCollection, shade_kw)
|
||||
small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, Poly3DCollection, shade_kw)
|
||||
return large, small
|
||||
|
||||
|
||||
def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) -> bytes:
|
||||
"""Render the prepared poly3d collection to an in-memory PNG."""
|
||||
@dataclass
|
||||
class _Object3MF:
|
||||
"""One ``<object>``: its own mesh, and the objects it places as components."""
|
||||
|
||||
vertices: object = None # np.ndarray (n, 3) or None
|
||||
faces: object = None # np.ndarray (m, 3) or None
|
||||
# (model path or None for "same file", object id, 4x4 transform)
|
||||
components: list = field(default_factory=list)
|
||||
|
||||
|
||||
def _local(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _transform(attr: str | None):
|
||||
"""A 3MF ``transform`` attribute as a 4x4 matrix for column vectors.
|
||||
|
||||
3MF lists the 3x4 matrix row by row for ROW vectors (``m00 m01 m02 m10 ...
|
||||
m32``, the last three being the translation); transposing it gives the usual
|
||||
column-vector form. Same reading as trimesh's ``_attrib_to_transform``.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
matrix = np.eye(4)
|
||||
if attr:
|
||||
values = [float(x) for x in attr.split()]
|
||||
if len(values) == 12:
|
||||
matrix[:3, :4] = np.array(values).reshape(4, 3).T
|
||||
return matrix
|
||||
|
||||
|
||||
def _parse_model_file(zf: zipfile.ZipFile, path: str) -> tuple[dict[str, _Object3MF], list]:
|
||||
"""Every object in one model file, and its build items (root file only).
|
||||
|
||||
Streams the file and drops each element as soon as it is read, so memory
|
||||
stays at the numbers collected rather than an XML tree — one Bambu model
|
||||
file seen in the wild is a single 163 MB mesh. lxml rather than the stdlib
|
||||
parser: ElementTree builds a Python object per vertex and took ~3x as long
|
||||
on that file. The input is untrusted, so entities, DTDs and network access
|
||||
are all off; trimesh, which this replaces here, parses the same files with
|
||||
lxml already.
|
||||
"""
|
||||
import numpy as np
|
||||
from lxml import etree
|
||||
|
||||
objects: dict[str, _Object3MF] = {}
|
||||
build: list = []
|
||||
vertices: list = []
|
||||
triangles: list = []
|
||||
components: list = []
|
||||
|
||||
parse = etree.iterparse(
|
||||
io.BytesIO(zf.read(path)),
|
||||
events=("end",),
|
||||
resolve_entities=False,
|
||||
no_network=True,
|
||||
load_dtd=False,
|
||||
)
|
||||
for _event, elem in parse:
|
||||
name = _local(elem.tag) if isinstance(elem.tag, str) else ""
|
||||
if name == "vertex":
|
||||
try:
|
||||
vertices.append((float(elem.get("x")), float(elem.get("y")), float(elem.get("z"))))
|
||||
except (TypeError, ValueError):
|
||||
vertices.append((0.0, 0.0, 0.0)) # keeps the indices of later vertices right
|
||||
elif name == "triangle":
|
||||
try:
|
||||
triangles.append((int(elem.get("v1")), int(elem.get("v2")), int(elem.get("v3"))))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif name == "component" and elem.get("objectid") is not None:
|
||||
# ``p:path`` (production extension): the object lives in another
|
||||
# model file. Bambu Studio and OrcaSlicer put every mesh in
|
||||
# ``3D/Objects/`` and place it this way.
|
||||
ref = next((val for key, val in elem.attrib.items() if _local(key) == "path"), None)
|
||||
components.append(
|
||||
(ref.lstrip("/") if ref else None, elem.get("objectid"), _transform(elem.get("transform")))
|
||||
)
|
||||
elif name == "object":
|
||||
obj = _Object3MF(components=components)
|
||||
if triangles:
|
||||
v = np.array(vertices, dtype=float).reshape(-1, 3)
|
||||
f = np.array(triangles, dtype=np.int64).reshape(-1, 3)
|
||||
# A triangle naming a vertex that isn't there would index past
|
||||
# the array at render time; drop it here instead.
|
||||
obj.vertices, obj.faces = v, f[(f >= 0).all(axis=1) & (f < len(v)).all(axis=1)]
|
||||
if elem.get("id") is not None:
|
||||
objects[elem.get("id")] = obj
|
||||
vertices, triangles, components = [], [], []
|
||||
elif name == "item" and elem.get("objectid") is not None:
|
||||
# The production extension allows ``p:path`` here too, naming the
|
||||
# file the object lives in; the root file when absent.
|
||||
ref = next((val for key, val in elem.attrib.items() if _local(key) == "path"), None)
|
||||
build.append((ref.lstrip("/") if ref else None, elem.get("objectid"), _transform(elem.get("transform"))))
|
||||
else:
|
||||
continue
|
||||
# Free what has been read: the element, and the siblings before it that
|
||||
# lxml would otherwise keep attached to the parent.
|
||||
elem.clear()
|
||||
while elem.getprevious() is not None:
|
||||
del elem.getparent()[0]
|
||||
return objects, build
|
||||
|
||||
|
||||
def _load_plate_geometry(zf: zipfile.ZipFile, trimesh, repair_winding):
|
||||
"""The plate as one (vertices, faces) pair, every instance placed, within budget.
|
||||
|
||||
Not ``trimesh.load``: its 3MF reader re-parses a ``p:path`` component's file
|
||||
for EVERY component that references it and appends the meshes again each
|
||||
time. Bambu Studio and OrcaSlicer write each instance as its own object with
|
||||
one such component, so N copies of a part came back as one mesh holding N
|
||||
copies of every triangle — N² of them once placed — while the vertex count,
|
||||
merged back down, looked normal. 25 bins of 10k faces loaded as 6.4M faces
|
||||
and took 8.4 GB to render (#3135; trimesh 4.12 and 5.1 alike).
|
||||
|
||||
Here each model file is parsed once and each mesh is kept once, decimated
|
||||
once to its share of the face budget, and only then placed per instance.
|
||||
Returns None when there is nothing to draw or the plate is over the ceiling.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
files: dict[str, dict[str, _Object3MF]] = {}
|
||||
names = set(zf.namelist())
|
||||
|
||||
def objects_in(path: str) -> dict[str, _Object3MF]:
|
||||
if path not in files:
|
||||
files[path] = _parse_model_file(zf, path)[0] if path in names else {}
|
||||
return files[path]
|
||||
|
||||
root_objects, build = _parse_model_file(zf, _MODEL_ROOT)
|
||||
files[_MODEL_ROOT] = root_objects
|
||||
|
||||
placements: dict[tuple[str, str], list] = defaultdict(list)
|
||||
count = 0
|
||||
|
||||
def place(path: str, object_id: str, matrix, depth: int, trail: frozenset) -> None:
|
||||
nonlocal count
|
||||
key = (path, object_id)
|
||||
if depth > _MAX_COMPONENT_DEPTH or key in trail or count > _MAX_PLACEMENTS:
|
||||
return
|
||||
obj = objects_in(path).get(object_id)
|
||||
if obj is None:
|
||||
return
|
||||
if obj.faces is not None and len(obj.faces):
|
||||
placements[key].append(matrix)
|
||||
count += 1
|
||||
for ref, child_id, child_matrix in obj.components:
|
||||
place(ref or path, child_id, matrix @ child_matrix, depth + 1, trail | {key})
|
||||
|
||||
for ref, object_id, matrix in build:
|
||||
place(ref or _MODEL_ROOT, object_id, matrix, 0, frozenset())
|
||||
|
||||
if count > _MAX_PLACEMENTS:
|
||||
logger.info("plate_thumbnail: over %d placed parts, skipping the thumbnail", _MAX_PLACEMENTS)
|
||||
return None
|
||||
if not placements:
|
||||
logger.debug("plate_thumbnail: 3MF places no mesh")
|
||||
return None
|
||||
|
||||
def faces_of(key) -> int:
|
||||
return len(files[key[0]][key[1]].faces)
|
||||
|
||||
def over_ceiling(faces: int) -> bool:
|
||||
if faces <= _MAX_PLACED_FACES:
|
||||
return False
|
||||
logger.info(
|
||||
"plate_thumbnail: %d faces even after decimation (ceiling %d), skipping the thumbnail",
|
||||
faces,
|
||||
_MAX_PLACED_FACES,
|
||||
)
|
||||
return True
|
||||
|
||||
total = sum(faces_of(key) * len(ms) for key, ms in placements.items())
|
||||
scale = min(1.0, _RENDER_FACE_BUDGET / total)
|
||||
targets = {key: max(_MIN_FACES_PER_MESH, int(faces_of(key) * scale)) for key in placements}
|
||||
# What decimation can actually reach: it removes at most 99% of a mesh, so a
|
||||
# part needing more keeps 1% of its faces rather than its target. Checked
|
||||
# before any mesh is built, so a hopeless plate costs nothing but the parse.
|
||||
reachable = sum(
|
||||
min(faces_of(key), max(targets[key], -(-faces_of(key) // 100))) * len(ms) for key, ms in placements.items()
|
||||
)
|
||||
if over_ceiling(reachable):
|
||||
return None
|
||||
|
||||
prepared = []
|
||||
for key, matrices in placements.items():
|
||||
obj = files[key[0]][key[1]]
|
||||
mesh = trimesh.Trimesh(vertices=obj.vertices, faces=obj.faces, process=True)
|
||||
if targets[key] < len(mesh.faces):
|
||||
try:
|
||||
# ``percent`` (the share to REMOVE), the form this module has always
|
||||
# called. ``face_count`` reaches the same size, but on a real 2M-face
|
||||
# model it left the winding inconsistent where ``percent`` did not,
|
||||
# which costs the repair below ~14 s.
|
||||
reduction = 1.0 - targets[key] / len(mesh.faces)
|
||||
mesh = mesh.simplify_quadric_decimation(max(0.01, min(0.99, reduction)))
|
||||
except Exception as exc:
|
||||
logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
|
||||
prepared.append((mesh, matrices))
|
||||
|
||||
# Again on what decimation delivered: it can stop short of its target, or
|
||||
# fail and leave the mesh whole, and the render's memory follows the faces
|
||||
# it is actually handed.
|
||||
if over_ceiling(sum(len(mesh.faces) * len(ms) for mesh, ms in prepared)):
|
||||
return None
|
||||
|
||||
all_vertices = []
|
||||
all_faces = []
|
||||
offset = 0
|
||||
for mesh, matrices in prepared:
|
||||
# Once per mesh, before it is placed: ``faces`` below index these vertices,
|
||||
# so a repair that ever moves one would leave the two out of step. Shared
|
||||
# with stl_thumbnail rather than copied — the renderers agree because they
|
||||
# run the same code.
|
||||
try:
|
||||
repair_winding(mesh, trimesh, "plate_thumbnail")
|
||||
except Exception as e: # best-effort, as the whole module is
|
||||
logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
|
||||
vertices = np.asarray(mesh.vertices, dtype=float)
|
||||
faces = np.asarray(mesh.faces, dtype=np.int64)
|
||||
for matrix in matrices:
|
||||
all_vertices.append(vertices @ matrix[:3, :3].T + matrix[:3, 3])
|
||||
# A mirroring transform turns every triangle inside out; flip the
|
||||
# winding back so shading still sees the outside.
|
||||
placed = faces[:, ::-1] if np.linalg.det(matrix[:3, :3]) < 0 else faces
|
||||
all_faces.append(placed + offset)
|
||||
offset += len(vertices)
|
||||
|
||||
return np.vstack(all_vertices), np.vstack(all_faces)
|
||||
|
||||
|
||||
def _render_at_size(poly3d, size: int, Poly3DCollection, shade_kw: dict) -> bytes:
|
||||
"""Render the prepared poly3d collection to an in-memory PNG.
|
||||
|
||||
Matplotlib's object API, not pyplot. This runs in a worker thread (#3135)
|
||||
while stl_thumbnail renders through pyplot on the event loop, and pyplot's
|
||||
figure registry and "current figure" are process-global: its
|
||||
``subplots_adjust`` would lay out whichever figure the other thread made
|
||||
last, and neither lock placement is acceptable — held on the loop it stalls
|
||||
the server for the whole plate render. A ``Figure`` with its own Agg canvas
|
||||
shares nothing, so the two can run at once.
|
||||
"""
|
||||
# Local, like every other import in this module, so importing plate_thumbnail
|
||||
# in an environment without matplotlib still works. stl_thumbnail's own
|
||||
# module level is import-light, so this costs nothing after the first call.
|
||||
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
from backend.app.services.stl_thumbnail import VIEW_AZIM_DEG, VIEW_ELEV_DEG
|
||||
|
||||
fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
|
||||
fig = Figure(figsize=(size / 100, size / 100), dpi=100)
|
||||
FigureCanvasAgg(fig)
|
||||
fig.patch.set_facecolor(_BACKGROUND_COLOR)
|
||||
ax = fig.add_subplot(111, projection="3d")
|
||||
ax.set_facecolor(_BACKGROUND_COLOR)
|
||||
@@ -230,7 +476,7 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) ->
|
||||
ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
|
||||
ax.set_axis_off()
|
||||
ax.grid(False)
|
||||
plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
||||
fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
||||
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(
|
||||
@@ -242,7 +488,6 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) ->
|
||||
pad_inches=0.05,
|
||||
dpi=100,
|
||||
)
|
||||
plt.close(fig)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import logging
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import get_stored_token
|
||||
from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
@@ -40,6 +39,7 @@ from backend.app.services.bambu_cloud import (
|
||||
BambuCloudError,
|
||||
BambuCloudService,
|
||||
)
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -58,11 +59,13 @@ from backend.app.services.printer_manager import (
|
||||
supports_drying_while_printing,
|
||||
)
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
from backend.app.utils.ams_humidity import ams_humidity_percent
|
||||
from backend.app.utils.color_utils import perceptual_color_distance
|
||||
from backend.app.utils.filament_types import canonical_filament_type
|
||||
from backend.app.utils.filename import derive_remote_filename
|
||||
from backend.app.utils.local_time import utcnow_naive
|
||||
from backend.app.utils.printer_models import (
|
||||
is_dual_nozzle_model,
|
||||
is_gcode_compatible,
|
||||
is_nozzle_rack_model,
|
||||
normalize_printer_model,
|
||||
@@ -471,6 +474,77 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
|
||||
_EXTERNAL_TRAY_ID_MIN = 254
|
||||
|
||||
|
||||
def _consumed_mapping_entries(mapping: list | None, required: list[dict] | None) -> list | None:
|
||||
"""The ``mapping`` entries for the slots this plate actually prints.
|
||||
|
||||
``required`` comes from ``extract_filament_requirements``, which drops any
|
||||
filament with ``used_g <= 0`` — so a slot_id present there is one the plate
|
||||
consumes, and one absent from it is padding. That distinction is why this
|
||||
decision lives here and not in the MQTT command builder: a ``-1`` in the
|
||||
mapping means either "this plate does not print filament N" or "we never
|
||||
worked out which tray", and only the plate's own filament list separates
|
||||
them. The builder sees both as the same byte, which is how a plate whose one
|
||||
printed filament sat on the external spool went out as `use_ams=true` with a
|
||||
mapping of nothing but -1 and stalled at preheat until the firmware gave up
|
||||
with 07FF_8012 (#3087).
|
||||
|
||||
Returns None whenever the two cannot be lined up — no mapping, no parsed
|
||||
requirements, or a requirement the mapping is too short to cover — so every
|
||||
caller falls back to existing behaviour rather than acting on a guess.
|
||||
"""
|
||||
if not isinstance(mapping, list) or not mapping or not required:
|
||||
return None
|
||||
entries = []
|
||||
for filament in required:
|
||||
slot_id = filament.get("slot_id")
|
||||
if not isinstance(slot_id, int) or not 1 <= slot_id <= len(mapping):
|
||||
# The mapping and the requirements disagree about how many filaments
|
||||
# the file has. They came from different reads, so judge nothing.
|
||||
return None
|
||||
entries.append(mapping[slot_id - 1])
|
||||
return entries or None
|
||||
|
||||
|
||||
def _is_external_tray(tray_id) -> bool:
|
||||
"""True for an explicit external-spool selection (254/255), not for an
|
||||
unresolved slot and not for an AMS tray."""
|
||||
if tray_id is None:
|
||||
return False
|
||||
try:
|
||||
return int(tray_id) >= _EXTERNAL_TRAY_ID_MIN
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _might_be_dual_nozzle(printer_model: str | None, status) -> bool:
|
||||
"""Whether this printer could have two extruders, judged generously.
|
||||
|
||||
On a dual-nozzle printer ``use_ams`` is nozzle routing rather than an
|
||||
AMS on/off flag — H2D Pro firmware reads it as an extruder index — which is
|
||||
why the MQTT command builder skips its own use_ams reconcile there. Anything
|
||||
that might be dual-nozzle therefore keeps whatever ``use_ams`` it arrived
|
||||
with, external spools or not.
|
||||
|
||||
Deliberately over-eager: a wrong "yes" only means this printer keeps the
|
||||
behaviour it has always had, while a wrong "no" would rewrite a field that
|
||||
steers which nozzle prints. The model name is the first answer (it is what
|
||||
the command builder falls back to as well), then the same live evidence the
|
||||
dispatcher's extruder annotation uses — a second nozzle reporting a
|
||||
diameter, a populated ``ams_extruder_map``, or more than one external feed,
|
||||
since a single-nozzle printer has exactly one.
|
||||
"""
|
||||
if is_dual_nozzle_model(printer_model):
|
||||
return True
|
||||
nozzles = getattr(status, "nozzles", None) or []
|
||||
if len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""):
|
||||
return True
|
||||
raw = getattr(status, "raw_data", None) or {}
|
||||
if raw.get("ams_extruder_map"):
|
||||
return True
|
||||
vt_trays = raw.get("vt_tray") or []
|
||||
return isinstance(vt_trays, list) and len(vt_trays) > 1
|
||||
|
||||
|
||||
def _int_or(value, default: int) -> int:
|
||||
"""``int(value)``, or ``default`` when the field is missing or junk.
|
||||
|
||||
@@ -1186,6 +1260,127 @@ class PrintScheduler:
|
||||
)
|
||||
busy_printers: set[int] = {pid for (pid,) in busy_result.all() if pid is not None}
|
||||
|
||||
# Why each printer left this pass, recorded where the decision is
|
||||
# made rather than re-derived when the summary is logged. #3018's
|
||||
# bundle shows what the old summary produced: "printer 1 not
|
||||
# available -- connected=True, state=IDLE" immediately followed by a
|
||||
# dispatch to printer 1. Two things went wrong at once. The line read
|
||||
# live state at log time, which by then no longer matched the state
|
||||
# the decision was made on; and `busy_printers` holds both printers
|
||||
# that cannot take work and printers this pass has claimed for it,
|
||||
# which are opposite facts. It is the first line anyone greps for
|
||||
# "why did my item not go out", so it has to say which.
|
||||
busy_reasons: dict[int, str] = dict.fromkeys(busy_printers, "an item is already printing on it")
|
||||
|
||||
# Printers this pass is dispatching to. They are in busy_printers so
|
||||
# nothing else in the pass targets them -- that is a reservation, not
|
||||
# an obstruction, and the summary says so.
|
||||
claimed_printers: set[int] = set()
|
||||
|
||||
def mark_busy(printer_id: int, reason: str) -> None:
|
||||
"""Take ``printer_id`` out of this pass, recording why.
|
||||
|
||||
First reason wins: a printer already excluded by a stronger fact
|
||||
-- a print running on it -- must not be relabelled by a weaker
|
||||
check that ran later and would have excluded it anyway.
|
||||
"""
|
||||
busy_printers.add(printer_id)
|
||||
busy_reasons.setdefault(printer_id, reason)
|
||||
|
||||
def claim_printer(printer_id: int) -> None:
|
||||
"""Reserve ``printer_id`` for an item this pass is dispatching."""
|
||||
claimed_printers.add(printer_id)
|
||||
mark_busy(printer_id, "selected for dispatch in this pass")
|
||||
|
||||
# The user-facing half of `busy_reasons` (#3074). The same decisions
|
||||
# worded for a different audience: the log wants "still inside its
|
||||
# post-dispatch hold window", the queue row wants to know the printer
|
||||
# is taken and nothing is broken. Only the cases that would read wrong
|
||||
# as a plain "Busy" are recorded here; the rest fall back to it.
|
||||
item_hold_reasons: dict[int, str] = {}
|
||||
|
||||
# Names and models for the printers this pass may have to write a
|
||||
# waiting reason about, read once rather than per skip per tick. The
|
||||
# model-based branch already has its names from `_printers_for_model`.
|
||||
pinned_printer_ids = {i.printer_id for i in items if i.printer_id}
|
||||
pinned_printers: dict[int, tuple[str, str]] = {}
|
||||
if pinned_printer_ids:
|
||||
pinned_rows = await db.execute(
|
||||
select(Printer.id, Printer.name, Printer.model).where(Printer.id.in_(pinned_printer_ids))
|
||||
)
|
||||
pinned_printers = {pid: (name or "", model or "") for pid, name, model in pinned_rows.all()}
|
||||
|
||||
def printer_label(printer_id: int) -> str:
|
||||
"""What to call this printer in a queue row."""
|
||||
entry = pinned_printers.get(printer_id)
|
||||
return (entry[0] if entry else "") or f"printer {printer_id}"
|
||||
|
||||
async def hold_item(item: PrintQueueItem, reason: str | None, *, notify: bool = True) -> None:
|
||||
"""Record why *item* is not going out, in the words the queue row shows.
|
||||
|
||||
The fixed-printer branch's single writer for ``waiting_reason``
|
||||
(#3074). Before this, the sensor interlock was the only thing that
|
||||
wrote the field there, so an item pinned to a printer that was
|
||||
merely printing sat at `pending` with nothing to show for it —
|
||||
indistinguishable from a queue that had stopped working — while
|
||||
the same job queued as "Any <model>" explained itself.
|
||||
|
||||
Every exit from that branch now calls this, which is also what
|
||||
replaced the interlock's old habit of clearing the field up front:
|
||||
a lifted hold cannot leave "Waiting on Enclosure Door" standing,
|
||||
because whichever exit runs next overwrites it and the dispatch
|
||||
path clears it.
|
||||
|
||||
A notification goes out when this item starts asking for
|
||||
something, and only then: the new reason needs the user, and what
|
||||
it replaced did not. "What it replaced did not" has to include a
|
||||
busy-only reason, not just an empty one. The sequence this branch
|
||||
actually produces is a print running (``Busy: X1C-01``) and then
|
||||
the plate it left behind (``Waiting for plate confirmation``), and
|
||||
testing "was the field empty" would call that no transition at all
|
||||
and stay quiet through the one case worth saying out loud.
|
||||
|
||||
The cost is that a printer dropping offline, coming back busy and
|
||||
dropping again asks twice rather than once. That is the honest
|
||||
reading — it went wrong twice — and the alternative was a rule
|
||||
that never fired for the case this was built for.
|
||||
|
||||
*notify* is how a caller opts out. The sensor interlock does: it
|
||||
has never sent this notification, and a change about what the
|
||||
queue *displays* is not the place to start (#1148).
|
||||
"""
|
||||
if item.waiting_reason == reason:
|
||||
return
|
||||
# Busy-only and empty are the same thing here: neither is the
|
||||
# queue asking the user for anything.
|
||||
was_asking = bool(item.waiting_reason) and not self._is_busy_only(item.waiting_reason)
|
||||
item.waiting_reason = reason
|
||||
await db.commit()
|
||||
if not notify or not reason or was_asking or self._is_busy_only(reason):
|
||||
return
|
||||
try:
|
||||
job_name = await self._get_job_name(db, item)
|
||||
entry = pinned_printers.get(item.printer_id) if item.printer_id else None
|
||||
await notification_service.on_queue_job_waiting(
|
||||
job_name=job_name,
|
||||
target_model=(entry[1] if entry else "") or "",
|
||||
waiting_reason=reason,
|
||||
db=db,
|
||||
)
|
||||
except Exception as e:
|
||||
# A queue that cannot say why it is waiting is the bug being
|
||||
# fixed here; a queue that stops dispatching because a
|
||||
# notification provider is down would be a worse one.
|
||||
logger.debug("Waiting notification failed for item %s: %s", item.id, e)
|
||||
|
||||
async def hold_for_printer(
|
||||
item: PrintQueueItem, printer_id: int, log_reason: str, item_reason: str
|
||||
) -> None:
|
||||
"""Take *printer_id* out of this pass and tell the item's owner why."""
|
||||
mark_busy(printer_id, log_reason)
|
||||
item_hold_reasons.setdefault(printer_id, item_reason)
|
||||
await hold_item(item, item_reason)
|
||||
|
||||
# Defense-in-depth (#1157): augment busy_printers with any printer
|
||||
# still in its post-dispatch hold window. Empirically, the DB seed
|
||||
# above can miss in-flight items in a multi-plate batch — same-file
|
||||
@@ -1196,7 +1391,7 @@ class PrintScheduler:
|
||||
# timing.
|
||||
for held_printer_id in list(self._dispatch_holds.keys()):
|
||||
if self._printer_in_dispatch_hold(held_printer_id):
|
||||
busy_printers.add(held_printer_id)
|
||||
mark_busy(held_printer_id, "still inside its post-dispatch hold window")
|
||||
|
||||
# Exclude printers whose upload is still in flight from an earlier
|
||||
# pass (#2602). The row is `pending` until the upload finishes and
|
||||
@@ -1205,7 +1400,7 @@ class PrintScheduler:
|
||||
# busy_printers, its auto-drying) out of the pass during the upload.
|
||||
for _task, inflight_pid in self._inflight.values():
|
||||
if inflight_pid is not None:
|
||||
busy_printers.add(inflight_pid)
|
||||
mark_busy(inflight_pid, "an upload to it is still in flight")
|
||||
|
||||
# Snapshot taken here, before the item loop adds anything (#2801).
|
||||
#
|
||||
@@ -1310,11 +1505,18 @@ class PrintScheduler:
|
||||
if sched.tzinfo is None:
|
||||
sched = sched.replace(tzinfo=timezone.utc)
|
||||
if sched > datetime.now(timezone.utc):
|
||||
# Waiting on the clock, not on a printer.
|
||||
await hold_item(item, None)
|
||||
skip_reasons["scheduled_future"] = skip_reasons.get("scheduled_future", 0) + 1
|
||||
continue
|
||||
|
||||
# Skip items that require manual start
|
||||
if item.manual_start:
|
||||
# Waiting on the user, not on a printer. Cleared here because
|
||||
# this is the last pass that will look at the row: a staged
|
||||
# item never reaches the branches below again, so a reason
|
||||
# left from before it was staged would stand forever (#3074).
|
||||
await hold_item(item, None)
|
||||
skip_reasons["manual_start"] = skip_reasons.get("manual_start", 0) + 1
|
||||
continue
|
||||
|
||||
@@ -1325,24 +1527,35 @@ class PrintScheduler:
|
||||
# to shut the enclosure" need to read differently, and only
|
||||
# one of them is something the user can fix.
|
||||
#
|
||||
# The interlock is the only thing that writes a
|
||||
# waiting_reason on this branch — the model-based branch
|
||||
# nulls it at the moment it assigns a printer — so any
|
||||
# reason still standing once the hold lifts is stale and is
|
||||
# cleared here. Doing it at dispatch instead would leave a
|
||||
# shut door reading "Waiting on Enclosure Door" for as long
|
||||
# as the printer stayed busy with something else.
|
||||
# It used to be the only thing that wrote a waiting_reason on
|
||||
# this branch, and it cleared the field up front so a lifted
|
||||
# hold could not leave a shut door reading "Waiting on
|
||||
# Enclosure Door". `hold_item` carries that guarantee now —
|
||||
# every exit below writes — so the clear is gone and the
|
||||
# interlock is an ordinary hold like the rest (#3074).
|
||||
interlock_reason = interlocked.get(item.printer_id)
|
||||
reason = f"Waiting on {interlock_reason}" if interlock_reason else None
|
||||
if item.waiting_reason != reason:
|
||||
item.waiting_reason = reason
|
||||
await db.commit()
|
||||
if interlock_reason:
|
||||
# Silent, exactly as it has always been. #1148 built this
|
||||
# as a hold that shows on the row, never as an alert, and
|
||||
# routing it through the shared writer must not quietly
|
||||
# turn every open door into a notification.
|
||||
await hold_item(item, f"Waiting on {interlock_reason}", notify=False)
|
||||
skip_reasons["sensor_interlock"] = skip_reasons.get("sensor_interlock", 0) + 1
|
||||
continue
|
||||
|
||||
# Specific printer assignment (existing behavior)
|
||||
if item.printer_id in busy_printers:
|
||||
# Whatever took the printer out of this pass — a print
|
||||
# already running on it, a post-dispatch hold, an upload
|
||||
# still in flight, an item ahead of this one in the same
|
||||
# pass — reads the same way from the queue: the printer is
|
||||
# taken and this item is in line for it. The exceptions
|
||||
# that do not (an offline printer, say) recorded their own
|
||||
# wording in `item_hold_reasons` when they held it.
|
||||
await hold_item(
|
||||
item,
|
||||
item_hold_reasons.get(item.printer_id) or f"Busy: {printer_label(item.printer_id)}",
|
||||
)
|
||||
continue
|
||||
|
||||
# Check if printer is idle
|
||||
@@ -1374,16 +1587,36 @@ class PrintScheduler:
|
||||
printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
|
||||
else:
|
||||
logger.warning("Could not power on printer %s via smart plug", item.printer_id)
|
||||
busy_printers.add(item.printer_id)
|
||||
await hold_for_printer(
|
||||
item,
|
||||
item.printer_id,
|
||||
"smart-plug power-on failed",
|
||||
f"Offline: {printer_label(item.printer_id)} — the smart plug could not power it on",
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# No plug or auto_on disabled
|
||||
busy_printers.add(item.printer_id)
|
||||
# No plug or auto_on disabled. Worded exactly as the
|
||||
# model-based branch words it (#2786): this is the one
|
||||
# entry on that list the user has to act on, because
|
||||
# Bambuddy will never switch this printer on itself.
|
||||
await hold_for_printer(
|
||||
item,
|
||||
item.printer_id,
|
||||
"offline, with no smart plug to power it on",
|
||||
f"Offline, no Auto On smart plug: {printer_label(item.printer_id)}",
|
||||
)
|
||||
continue
|
||||
|
||||
# Check if printer is idle (busy with another print)
|
||||
if not printer_idle:
|
||||
busy_printers.add(item.printer_id)
|
||||
await hold_for_printer(
|
||||
item,
|
||||
item.printer_id,
|
||||
"not idle",
|
||||
self._pinned_hold_reason(
|
||||
item.printer_id, printer_label(item.printer_id), require_plate_clear
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
# Drying blocks the queue, if the user asked it to. A hold
|
||||
@@ -1392,7 +1625,14 @@ class PrintScheduler:
|
||||
if self._drying_in_progress.get(item.printer_id) and await self._get_bool_setting(
|
||||
db, "queue_drying_block"
|
||||
):
|
||||
busy_printers.add(item.printer_id)
|
||||
# Busy-shaped on purpose: the cycle ends on its own and
|
||||
# the job goes out, so there is nothing to alert about.
|
||||
await hold_for_printer(
|
||||
item,
|
||||
item.printer_id,
|
||||
"drying, and drying is set to block the queue",
|
||||
f"Busy: {printer_label(item.printer_id)} (drying)",
|
||||
)
|
||||
continue
|
||||
|
||||
# Check condition (previous print success)
|
||||
@@ -1401,6 +1641,8 @@ class PrintScheduler:
|
||||
item.status = "skipped"
|
||||
item.error_message = "Previous print failed or was aborted"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
# Not pending any more, so not waiting for anything.
|
||||
item.waiting_reason = None
|
||||
await db.commit()
|
||||
logger.info("Skipped queue item %s - previous print failed", item.id)
|
||||
|
||||
@@ -1430,6 +1672,10 @@ class PrintScheduler:
|
||||
# promote the item to manual_start so the user must
|
||||
# acknowledge via the ▶ button (which re-checks live).
|
||||
if await self._block_on_filament_deficit(db, item):
|
||||
# Now staged for the user to start by hand, and the row
|
||||
# shows the filament-short badge instead. Cleared because
|
||||
# a staged item never reaches this branch again.
|
||||
await hold_item(item, None)
|
||||
continue
|
||||
|
||||
# Hold this item back for the next pass rather than racing
|
||||
@@ -1438,7 +1684,12 @@ class PrintScheduler:
|
||||
# its place in this printer's queue.
|
||||
if _library_row_conflict(item):
|
||||
skip_reasons["library_row_in_use"] = skip_reasons.get("library_row_in_use", 0) + 1
|
||||
busy_printers.add(item.printer_id)
|
||||
await hold_for_printer(
|
||||
item,
|
||||
item.printer_id,
|
||||
"holding its place while another item releases a library row",
|
||||
f"Busy: {printer_label(item.printer_id)}",
|
||||
)
|
||||
continue
|
||||
|
||||
# Print takes priority: stop a cycle Bambuddy armed, now
|
||||
@@ -1466,9 +1717,14 @@ class PrintScheduler:
|
||||
# Queue the dispatch instead of running it here — see
|
||||
# _dispatch_selected(). busy_printers still gets the printer
|
||||
# immediately, so nothing else in this pass can target it.
|
||||
#
|
||||
# The reason goes first: this item is not waiting for anything
|
||||
# any more, and the model-based branch clears its own at the
|
||||
# equivalent moment (#3074).
|
||||
await hold_item(item, None)
|
||||
_claim_library_row(item)
|
||||
dispatch_ids.append(item.id)
|
||||
busy_printers.add(item.printer_id)
|
||||
claim_printer(item.printer_id)
|
||||
|
||||
# SJF starvation guard: mark items that were jumped
|
||||
if sjf_enabled and item.print_time_seconds is not None:
|
||||
@@ -1674,7 +1930,7 @@ class PrintScheduler:
|
||||
|
||||
_claim_library_row(item)
|
||||
dispatch_ids.append(item.id)
|
||||
busy_printers.add(printer_id)
|
||||
claim_printer(printer_id)
|
||||
|
||||
# SJF starvation guard: mark model-based items that were jumped
|
||||
if sjf_enabled and item.print_time_seconds is not None:
|
||||
@@ -1701,20 +1957,24 @@ class PrintScheduler:
|
||||
# useless for working out why an item did not go out.
|
||||
if skip_reasons:
|
||||
logger.info("Queue skip summary: %s", skip_reasons)
|
||||
if busy_printers:
|
||||
# Log why each printer was busy (first time it was checked)
|
||||
for pid in busy_printers:
|
||||
state = printer_manager.get_status(pid)
|
||||
connected = printer_manager.is_connected(pid)
|
||||
awaiting = printer_manager.is_awaiting_plate_clear(pid)
|
||||
state_name = state.state if state else "NO_STATUS"
|
||||
logger.info(
|
||||
"Queue: printer %d not available — connected=%s, state=%s, awaiting_plate_clear=%s",
|
||||
pid,
|
||||
connected,
|
||||
state_name,
|
||||
awaiting,
|
||||
)
|
||||
for pid in sorted(busy_printers):
|
||||
reason = busy_reasons.get(pid, "no reason recorded")
|
||||
if pid in claimed_printers:
|
||||
logger.info("Queue: printer %d reserved — %s", pid, reason)
|
||||
continue
|
||||
# The three live fields stay, because they are what someone
|
||||
# reading a bundle wants next -- but they are labelled as read
|
||||
# now, not as the state the decision was made on, which is what
|
||||
# made the old line contradict itself.
|
||||
state = printer_manager.get_status(pid)
|
||||
logger.info(
|
||||
"Queue: printer %d unavailable — %s (now: connected=%s, state=%s, awaiting_plate_clear=%s)",
|
||||
pid,
|
||||
reason,
|
||||
printer_manager.is_connected(pid),
|
||||
state.state if state else "NO_STATUS",
|
||||
printer_manager.is_awaiting_plate_clear(pid),
|
||||
)
|
||||
|
||||
# Keep-warm is a comfort feature; dispatch is not. It sits between
|
||||
# selection and `_launch_uploads`, so anything raising here would
|
||||
@@ -3672,6 +3932,28 @@ class PrintScheduler:
|
||||
logger.debug("Printer %d: not idle — state=%s", printer_id, state.state)
|
||||
return idle
|
||||
|
||||
@staticmethod
|
||||
def _pinned_hold_reason(printer_id: int, printer_name: str, require_plate_clear: bool) -> str:
|
||||
"""Why a connected, non-idle printer cannot take this job, for the queue row (#3074).
|
||||
|
||||
Only ever asked about a printer :meth:`_is_printer_idle` has just refused
|
||||
and that the fixed-printer branch has already found connected, so the two
|
||||
offline cases answer at their own exits and never arrive here.
|
||||
|
||||
The default is the model-based branch's ``Busy:`` wording, which
|
||||
:meth:`_is_busy_only` reads as "resolves itself, stay quiet". That is also
|
||||
the right answer for the connected-but-no-telemetry second or two after a
|
||||
reconnect: the model-based branch has always reported it that way, and it
|
||||
is not something to wake anybody up for.
|
||||
|
||||
A plate nobody has confirmed is the one case here that does not resolve
|
||||
itself — somebody has to walk over to the printer — so it is worded as
|
||||
itself and allowed to notify.
|
||||
"""
|
||||
if require_plate_clear and printer_manager.is_awaiting_plate_clear(printer_id):
|
||||
return f"Waiting for plate confirmation: {printer_name}"
|
||||
return f"Busy: {printer_name}"
|
||||
|
||||
async def _get_setting(self, db: AsyncSession, key: str) -> str | None:
|
||||
"""Read a setting value from the database."""
|
||||
result = await db.execute(select(Settings).where(Settings.key == key))
|
||||
@@ -3736,6 +4018,61 @@ class PrintScheduler:
|
||||
continue
|
||||
return out
|
||||
|
||||
# Materials whose AMS spelling differs from the key the tables above use.
|
||||
# Bambu labels nylon "PA" while its own composites spell the family out, so
|
||||
# PA6, PA11, PA12 and PAHT would otherwise miss a table with a perfectly
|
||||
# good PA row (#3067).
|
||||
#
|
||||
# Mirrors DRYING_MATERIAL_ALIASES in frontend/src/utils/dryingPresets.ts.
|
||||
# The drying popover has resolved these correctly since #2774 and the
|
||||
# scheduler never did, which is exactly why #3067's reporter could dry a
|
||||
# PA6-CF spool by hand while auto-drying skipped it every pass.
|
||||
#
|
||||
# PPA is here too. Polyphthalamide is a distinct polymer rather than a grade
|
||||
# of nylon, so it is the one entry that is a judgement rather than a
|
||||
# spelling -- but it is an aromatic polyamide, it absorbs moisture the same
|
||||
# way, and PA's row is the hottest the table has. Drying it there is closer
|
||||
# to right than not drying it at all, which is what it got before.
|
||||
FILAMENT_KEY_ALIASES: dict[str, str] = {
|
||||
"NYLON": "PA",
|
||||
"PA6": "PA",
|
||||
"PA11": "PA",
|
||||
"PA12": "PA",
|
||||
"PAHT": "PA",
|
||||
"PPA": "PA",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _resolve_filament_key(cls, tray_type: str | None, table: Mapping[str, object]) -> str | None:
|
||||
"""The key in *table* that answers for this tray's material, or None.
|
||||
|
||||
The printer reports the material in ``tray_type``, and it spells filled
|
||||
and foamed variants out: PLA-CF, PETG-CF, ABS-GF, PLA-AERO, PA6-CF. The
|
||||
tables here are keyed by base material, so matching the raw string alone
|
||||
found a row for 8 of the 41 types a printer can report and skipped the
|
||||
rest -- silently, because every caller reads "no row" as "nothing to do
|
||||
for this tray". Auto-drying therefore ignored every composite spool on
|
||||
the install (#3067).
|
||||
|
||||
Exact match first, so a table the user has extended with a row of its
|
||||
own -- ``PA6-CF`` at a temperature they picked -- still wins over the
|
||||
base material's. Then the suffix is dropped, then the alias map above
|
||||
answers for the polyamide spellings.
|
||||
|
||||
Returns None rather than a default: what to do with an unrecognised
|
||||
material differs per caller, and only the caller knows whether "no row"
|
||||
means skip the tray or fall back to a catch-all. Nothing here invents a
|
||||
temperature for a material the table does not list.
|
||||
"""
|
||||
raw = cls._normalize_filament_type(tray_type or "")
|
||||
if not raw:
|
||||
return None
|
||||
for candidate in (raw, raw.split("-")[0]):
|
||||
key = cls.FILAMENT_KEY_ALIASES.get(candidate, candidate)
|
||||
if key in table:
|
||||
return key
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_humidity_threshold(trays: list[dict], thresholds: dict[str, int], fallback: int) -> int:
|
||||
"""Resolve the effective humidity threshold for an AMS unit (#1605).
|
||||
@@ -3755,8 +4092,10 @@ class PrintScheduler:
|
||||
tray_type = str(tray.get("tray_type") or "").strip()
|
||||
if not tray_type:
|
||||
continue
|
||||
base_type = tray_type.split()[0].upper()
|
||||
candidates.append(thresholds.get(base_type, default))
|
||||
# A composite carries its base material's threshold when it has no
|
||||
# row of its own, the same way it takes its drying preset (#3067).
|
||||
key = PrintScheduler._resolve_filament_key(tray_type, thresholds)
|
||||
candidates.append(thresholds[key] if key is not None else default)
|
||||
if not candidates:
|
||||
return default
|
||||
return min(candidates)
|
||||
@@ -3779,9 +4118,17 @@ class PrintScheduler:
|
||||
tray_type = tray.get("tray_type", "")
|
||||
if not tray_type:
|
||||
continue
|
||||
# Normalize filament type for preset lookup (e.g., "PLA Basic" -> "PLA")
|
||||
base_type = tray_type.split()[0].upper()
|
||||
preset = presets.get(base_type)
|
||||
# "PLA Basic" -> PLA, and "PA6-CF" -> PA rather than nothing at all,
|
||||
# which is what stopped auto-drying on every composite spool (#3067).
|
||||
base_type = self._resolve_filament_key(tray_type, presets)
|
||||
if base_type is None:
|
||||
continue
|
||||
# The table is user-editable JSON with no per-row validation, so a
|
||||
# row can be present and empty. That has always meant "skip this
|
||||
# material", and it has to keep meaning it: the reads below fall
|
||||
# back to 55C/12h per missing field, which is a temperature nobody
|
||||
# chose and would deform a PLA spool.
|
||||
preset = presets[base_type]
|
||||
if not preset:
|
||||
continue
|
||||
|
||||
@@ -3951,21 +4298,12 @@ class PrintScheduler:
|
||||
|
||||
dry_time = int(ams_data.get("dry_time") or 0)
|
||||
|
||||
# Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5)
|
||||
humidity = None
|
||||
h_raw = ams_data.get("humidity_raw")
|
||||
if h_raw is not None:
|
||||
try:
|
||||
humidity = int(h_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if humidity is None:
|
||||
h_idx = ams_data.get("humidity")
|
||||
if h_idx is not None:
|
||||
try:
|
||||
humidity = int(h_idx)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Read humidity as a percentage. The 1-5 index is never
|
||||
# substituted: it is inverted, and being unable to exceed any
|
||||
# threshold it would read as "dry" forever (#3140). ``None``
|
||||
# already means "skip this unit" everywhere below.
|
||||
humidity_pct = ams_humidity_percent(ams_data)
|
||||
humidity = int(round(humidity_pct)) if humidity_pct is not None else None
|
||||
unit_key = (pid, ams_id)
|
||||
unit_state = self._auto_dry_units.get(unit_key)
|
||||
|
||||
@@ -4581,8 +4919,18 @@ class PrintScheduler:
|
||||
"""Reduce the printer's tray_type to a preset-lookup key. Mirrors the
|
||||
existing drying-preset normalisation (split-at-space, upper-case) so
|
||||
the two maps share vocabulary — "PLA Basic" → "PLA", "PA-CF" stays
|
||||
"PA-CF" (no space to split on)."""
|
||||
return tray_type.split()[0].upper() if tray_type else ""
|
||||
"PA-CF" (no space to split on).
|
||||
|
||||
This is the first stage of ``_resolve_filament_key``, which goes on to
|
||||
drop the suffix and consult the alias map; on its own it only decides
|
||||
what the tray is called, not which row answers for it.
|
||||
|
||||
Indexing the split rather than testing the input: a tray_type of spaces
|
||||
is truthy and splits to nothing, so the old ``if tray_type`` guard let
|
||||
it through to an IndexError.
|
||||
"""
|
||||
words = (tray_type or "").split()
|
||||
return words[0].upper() if words else ""
|
||||
|
||||
def _target_for_tray_type(self, tray_type: str | None, targets: dict[str, int]) -> int:
|
||||
"""Per-filament chamber target for one tray's reported type, or 0 when
|
||||
@@ -4593,14 +4941,19 @@ class PrintScheduler:
|
||||
not the 0 an unknown type falls to. The specific type is still tried
|
||||
first, so PETG-CF and PA-CF keep the hotter rows they are listed with
|
||||
(#2902).
|
||||
|
||||
That lookup is now the shared one, which adds the polyamide aliases on
|
||||
top of the suffix it already dropped -- so PA6-CF reaches PA's row here
|
||||
too, rather than the catch-all it was landing on (#3067).
|
||||
"""
|
||||
normalised = self._normalize_filament_type(tray_type or "")
|
||||
if not normalised:
|
||||
if not self._normalize_filament_type(tray_type or ""):
|
||||
return 0
|
||||
target = targets.get(normalised)
|
||||
if target is None:
|
||||
target = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
|
||||
return target
|
||||
key = self._resolve_filament_key(tray_type, targets)
|
||||
if key is not None:
|
||||
return targets[key]
|
||||
# A type the map does not list at all, which is not the same as a tray
|
||||
# with nothing in it -- that already returned 0 above.
|
||||
return targets.get("DEFAULT", 0)
|
||||
|
||||
def _derive_chamber_target(
|
||||
self,
|
||||
@@ -5081,6 +5434,34 @@ class PrintScheduler:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _preheat_flap_to_cooling(self, item_id: int, printer: Printer) -> None:
|
||||
"""Put the airduct flap back to cooling for a print that wants no chamber heat.
|
||||
|
||||
The full preheat stage does this as part of its own dispatch: an H2D
|
||||
left in heating mode by the ABS job before it would otherwise cook the
|
||||
PLA that follows. The skip path never reaches that code, so it calls
|
||||
this instead -- one idempotent MQTT command, no waiting, and nothing to
|
||||
add to the rollback pin, because a flap set to cooling for a print that
|
||||
needs no heat is where it should have been either way.
|
||||
|
||||
Best-effort like everything else in the stage: a refused command logs
|
||||
and the dispatch carries on.
|
||||
"""
|
||||
model = printer.model or ""
|
||||
if not supports_airduct(model):
|
||||
return
|
||||
state = printer_manager.get_status(printer.id)
|
||||
current = getattr(state, "airduct_mode", None) if state else None
|
||||
if current == _AIRDUCT_MODE_COOLING:
|
||||
return
|
||||
client = printer_manager.get_client(printer.id)
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.set_airduct_mode("cooling")
|
||||
except Exception as exc:
|
||||
logger.warning("Queue item %s: preheat-skip airduct cooling failed: %s", item_id, exc)
|
||||
|
||||
async def _preheat_and_soak(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -5104,9 +5485,14 @@ class PrintScheduler:
|
||||
2. Chamber target — `item.preheat_chamber_target_override` if non-null;
|
||||
else max of `preheat_filament_targets[normalize(t.tray_type)]`
|
||||
across the trays `item.ams_mapping` names (every loaded slot when
|
||||
it names none); else 0 (skips chamber phase, keeps bed phase +
|
||||
soak timer).
|
||||
3. Three hardware tiers branch the wait loop:
|
||||
it names none).
|
||||
3. A target of 0 off the filament map skips the whole stage: the
|
||||
materials this print loads want no chamber, so there is nothing to
|
||||
soak for and the bed phase would only delay the upload (#3041).
|
||||
An explicit 0 typed into the per-item override, or a per-item
|
||||
'on', still runs the bed phase and the soak — both are the user
|
||||
asking for a warm bed in so many words.
|
||||
4. Three hardware tiers branch the wait loop:
|
||||
- Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E via supports_chamber_heater):
|
||||
send M141 to the resolved target, then wait for the chamber sensor
|
||||
to reach it (or the max-wait timeout to elapse).
|
||||
@@ -5142,9 +5528,10 @@ class PrintScheduler:
|
||||
# Chamber target resolution:
|
||||
# 1. Explicit per-item override beats everything (user knows best).
|
||||
# 2. Otherwise derive from the filament types this print loads, via
|
||||
# the per-filament target map. PLA-only print derives 0 → chamber
|
||||
# phase auto-skips without the user touching anything, even when
|
||||
# an ASA spool is sitting in another slot of the same AMS (#2886).
|
||||
# the per-filament target map. A PLA-only print derives 0 and the
|
||||
# block below skips the stage without the user touching anything,
|
||||
# even when an ASA spool is sitting in another slot of the same
|
||||
# AMS (#2886).
|
||||
explicit_target = getattr(item, "preheat_chamber_target_override", None)
|
||||
if explicit_target is not None and explicit_target > 0:
|
||||
chamber_target = int(explicit_target)
|
||||
@@ -5157,6 +5544,31 @@ class PrintScheduler:
|
||||
chamber_target = self._derive_chamber_target(printer, targets, item)
|
||||
chamber_source = "filament-map"
|
||||
|
||||
# Nothing to preheat *for*. A zero that came out of the filament map is
|
||||
# the map saying this print's materials want no chamber conditioning --
|
||||
# PLA, PETG, TPU and PVA all sit at 0 by default. Running the stage
|
||||
# anyway heated the bed and then held it for the full soak, which
|
||||
# delayed every PLA dispatch by minutes and bought nothing: the print's
|
||||
# own G-code sets the bed the moment it starts, so preheating it here
|
||||
# only moves that heating ahead of the upload instead of overlapping
|
||||
# with it, and the soak has no chamber to condition (#3041).
|
||||
#
|
||||
# An explicit statement from the user still runs the stage. Forcing the
|
||||
# per-item override to 'on', or typing a chamber target of exactly 0,
|
||||
# both mean "preheat the bed for this print" -- the second is
|
||||
# documented as doing precisely that. Only the automatic path, the
|
||||
# global toggle plus the filament map, short-circuits here.
|
||||
if chamber_target <= 0 and chamber_source == "filament-map" and override != "on":
|
||||
logger.info(
|
||||
"Queue item %s: preheat skipped -- the loaded filaments derive no chamber "
|
||||
"target, so there is nothing to soak for (override=%s model=%s)",
|
||||
item.id,
|
||||
override,
|
||||
printer.model or "",
|
||||
)
|
||||
self._preheat_flap_to_cooling(item.id, printer)
|
||||
return True
|
||||
|
||||
bed_target = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
|
||||
if bed_target <= 0:
|
||||
# No bed temperature in the slicer metadata. When the print needs a
|
||||
@@ -6632,6 +7044,54 @@ class PrintScheduler:
|
||||
if slot_extruders:
|
||||
nozzle_slot_extruders = json.dumps(slot_extruders)
|
||||
|
||||
# Every filament this plate prints is on the external spool -> the print
|
||||
# must go out with use_ams=False. The firmware answers use_ams=true plus
|
||||
# a mapping it cannot resolve with 07FF_8012 "Failed to get AMS mapping
|
||||
# table", which is what held the reporter's P1S at Heatbed preheating
|
||||
# for ten minutes before it gave up (#3087). The MQTT command builder
|
||||
# already downgrades a mapping that is *only* external ([254]), but a
|
||||
# multi-filament project pads the slots this plate does not print with
|
||||
# -1 — BambuStudio's own convention — and down there a -1 is
|
||||
# indistinguishable from a slot that never resolved, which must never be
|
||||
# sent to the spool holder (#2589). Here the plate's filament list says
|
||||
# which is which, so the answer is exact rather than a guess.
|
||||
#
|
||||
# Deliberately narrow: this fires only when every consumed slot is an
|
||||
# explicit 254/255. A consumed slot that did not resolve leaves use_ams
|
||||
# alone and the firmware still rejects the print, exactly as today. And
|
||||
# only for single-nozzle printers, mirroring the builder's own reconcile
|
||||
# — on a dual-nozzle machine use_ams is which extruder to feed, not
|
||||
# whether to use the AMS, so it is not ours to rewrite.
|
||||
effective_use_ams = item.use_ams
|
||||
if (
|
||||
effective_use_ams
|
||||
and ams_mapping
|
||||
and file_path is not None
|
||||
# Cheap gate before opening the file: with nothing on the spool
|
||||
# holder anywhere in the mapping, no subset of it can be all
|
||||
# external, so most dispatches never pay for the parse. The
|
||||
# isinstance also keeps a malformed stored mapping (a bare number
|
||||
# from a hand-edited row) failing where it always failed, in the
|
||||
# command builder, rather than here.
|
||||
and isinstance(ams_mapping, list)
|
||||
and any(_is_external_tray(t) for t in ams_mapping)
|
||||
and not _might_be_dual_nozzle(printer.model, pre_status)
|
||||
):
|
||||
from backend.app.services.filament_requirements import extract_filament_requirements
|
||||
|
||||
consumed = _consumed_mapping_entries(
|
||||
ams_mapping, extract_filament_requirements(file_path, plate_id=item.plate_id or 1)
|
||||
)
|
||||
if consumed and all(_is_external_tray(t) for t in consumed):
|
||||
effective_use_ams = False
|
||||
logger.info(
|
||||
"Queue item %s: every filament plate %s prints is on the external spool "
|
||||
"(mapping %s) — dispatching with use_ams=False (#3087)",
|
||||
item.id,
|
||||
item.plate_id or 1,
|
||||
ams_mapping,
|
||||
)
|
||||
|
||||
# Start the print with AMS mapping, plate_id and print options.
|
||||
# nozzle_mapping rides through verbatim — JSON string captured from
|
||||
# Bambu Studio's project_file on VP intake (#1780); the MQTT layer
|
||||
@@ -6648,7 +7108,7 @@ class PrintScheduler:
|
||||
vibration_cali=item.vibration_cali,
|
||||
layer_inspect=item.layer_inspect,
|
||||
timelapse=effective_timelapse,
|
||||
use_ams=item.use_ams,
|
||||
use_ams=effective_use_ams,
|
||||
nozzle_offset_cali=item.nozzle_offset_cali,
|
||||
nozzle_mapping=item.nozzle_mapping
|
||||
or (json.dumps(resolved_nozzle_mapping) if resolved_nozzle_mapping else None),
|
||||
|
||||
@@ -4,8 +4,8 @@ Bambuddy reads a print's 3MF, cover and timelapse off the printer over implicit
|
||||
FTPS on port 990. On every Bambu model that port serves **external storage only**
|
||||
-- the SD card or USB stick. It is not a view of the printer's filesystem.
|
||||
|
||||
H2-series and P2S firmware default to keeping the sliced file on internal eMMC
|
||||
instead, and BambuStudio uploads there over a separate service on port 6000
|
||||
H2-series, P2S and X2D firmware default to keeping the sliced file on internal
|
||||
eMMC instead, and BambuStudio uploads there over a separate service on port 6000
|
||||
(the "BambuTunnelLocal" protocol -- see #2762, which tracks implementing it).
|
||||
The dispatch says where it went: the ``project_file`` command carries ``url``,
|
||||
which is ``ftp://<name>`` for external storage and ``brtc://emmc/<name>`` for
|
||||
@@ -84,6 +84,18 @@ REASON_INTERNAL_HISTORY = "internal_history"
|
||||
# retry is worth scheduling (#2957).
|
||||
REASON_FTPS_COOLOFF = "ftps_cooloff"
|
||||
|
||||
# Also not a storage verdict, and the file's location was never in question
|
||||
# here either: the print went to external storage, FTPS served it, and the
|
||||
# transfer still did not finish inside its budget. At print start the printer is
|
||||
# also handling MQTT, the camera and the job upload, and a large 3MF does not
|
||||
# reliably complete against that -- #3063's reporter watched the same 19MB file
|
||||
# download successfully three times in the two minutes after the archive flow
|
||||
# gave up on it. Like the cool-off above and unlike the three storage verdicts,
|
||||
# this one is temporary and worth a retry; unlike the cool-off, nothing has to
|
||||
# expire first. Stamped by the print-start handler, which is the only place that
|
||||
# knows an attempt was made and failed in transit rather than answering 550.
|
||||
REASON_FTP_TRANSFER_FAILED = "ftp_transfer_failed"
|
||||
|
||||
# Where a sliced file has ever been found over FTPS, in the order the sweep in
|
||||
# `main.py` tries them -- root first, which is where A1/P1-series uploads land
|
||||
# (#972), then `/cache`, which is where the H2D keeps its copy of an eMMC job
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Runs the checks a maintainer performs by hand when triaging a
|
||||
"printer won't connect / won't print" report — port reachability, LAN
|
||||
developer mode, Docker network mode, subnet match, and MQTT credentials —
|
||||
developer mode, container network mode, subnet match, and MQTT credentials —
|
||||
so users can self-diagnose setup problems instead of opening an issue.
|
||||
|
||||
See the 2026-05-21 issue-triage analysis: ~1/3 of closed issues were
|
||||
@@ -12,16 +12,21 @@ user-side setup errors clustered on exactly these causes.
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
|
||||
from backend.app.services.bambu_ftp import find_remote_file_async
|
||||
from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
|
||||
from backend.app.services.camera import get_camera_port
|
||||
from backend.app.services.discovery import is_running_in_docker
|
||||
from backend.app.services.discovery import OCI_RUNTIMES, detect_container_runtime
|
||||
from backend.app.services.ftp_profiles import get_ftp_profile
|
||||
from backend.app.services.network_utils import find_local_ipv4_network
|
||||
from backend.app.services.print_storage import (
|
||||
REASON_INTERNAL_STORAGE,
|
||||
StorageVerdict,
|
||||
@@ -203,48 +208,202 @@ def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
|
||||
return camera_port, "RTSPS"
|
||||
|
||||
|
||||
def _detect_docker_network_mode() -> str:
|
||||
"""Detect Docker network mode.
|
||||
# Interfaces a container engine creates on the *host*. Seeing one of them
|
||||
# means we are in the host's network namespace.
|
||||
_HOST_INFRA_PREFIXES = ("docker", "br-", "veth", "virbr", "podman", "cni-", "cni_")
|
||||
|
||||
In host mode the container shares the host network namespace, so Docker
|
||||
infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
|
||||
mode the container only sees its own eth0.
|
||||
|
||||
def _has_native_interface() -> bool:
|
||||
"""True if some interface here was created in this network namespace.
|
||||
|
||||
A NAT-networked container is handed one end of a veth pair per attached
|
||||
network, and a veth's ``iflink`` points at its peer's index in the *other*
|
||||
namespace, so it never equals its own ``ifindex``. An interface where the
|
||||
two agree was made here — a physical NIC, a bridge, a VLAN — which a
|
||||
container with its own namespace does not get.
|
||||
|
||||
tun/tap devices are skipped: a container can legitimately run its own
|
||||
WireGuard or Tailscale client, and that tun would otherwise read as
|
||||
evidence of a namespace it is not evidence of.
|
||||
"""
|
||||
try:
|
||||
entries = [(idx, name) for idx, name in socket.if_nameindex() if name != "lo"]
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
for index, name in entries:
|
||||
# Never user input: the kernel's own interface table, and never a path.
|
||||
iface = Path("/sys/class/net") / name # SEC-PATH-OK: name from socket.if_nameindex()
|
||||
if (iface / "tun_flags").exists():
|
||||
continue
|
||||
try:
|
||||
ifindex = (iface / "ifindex").read_text().strip()
|
||||
iflink = (iface / "iflink").read_text().strip()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
# sysfs is tagged by network namespace, but a container given a bind
|
||||
# mount of the host's /sys sees the host's interfaces under names that
|
||||
# may collide with its own. Reading a different interface's numbers
|
||||
# would be reading another namespace's answer, so require that the
|
||||
# entry found here is the one the kernel just named.
|
||||
if ifindex != str(index):
|
||||
continue
|
||||
if ifindex == iflink:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detect_container_network_mode(runtime: str | None) -> str | None:
|
||||
"""Return "host", "bridge", or None when it genuinely cannot be told.
|
||||
|
||||
The first rule is the original Docker one and is kept exactly: a Docker
|
||||
*host* always has a docker0, so a container that can see it shares the
|
||||
host's namespace. It says nothing about Podman, which on a host running
|
||||
no bridge containers creates no such interface at all — which is how a
|
||||
host-networked Podman container came to be told it was on bridge
|
||||
networking (#3092).
|
||||
|
||||
The second rule is the general form of the same idea and is what answers
|
||||
for Podman. The third is the fallback the first rule always implied: an
|
||||
OCI container that can see neither is isolated, which is what bridge
|
||||
networking means.
|
||||
"""
|
||||
try:
|
||||
for _idx, name in socket.if_nameindex():
|
||||
if name.startswith(("docker", "br-", "veth", "virbr")):
|
||||
if name.startswith(_HOST_INFRA_PREFIXES):
|
||||
return "host"
|
||||
except Exception:
|
||||
pass
|
||||
return "bridge"
|
||||
if _has_native_interface():
|
||||
return "host"
|
||||
if runtime in OCI_RUNTIMES:
|
||||
return "bridge"
|
||||
return None
|
||||
|
||||
|
||||
def _get_host_ip() -> str | None:
|
||||
"""Best-effort IPv4 address the Bambuddy host routes from."""
|
||||
def _host_source_ip(destination_ip: str) -> str | None:
|
||||
"""The local IPv4 address Bambuddy would send from toward ``destination_ip``.
|
||||
|
||||
Asking about the printer's own address rather than a fixed far-away one
|
||||
matters on any host with more than one NIC: the source for a route to the
|
||||
internet is simply not the source for a route to the printer, and
|
||||
comparing the printer against the wrong interface is a warning about
|
||||
nothing (#3092).
|
||||
|
||||
Literals only. ``connect()`` on a name would resolve it, and this runs on
|
||||
the event loop; ``_same_subnet`` rejects names anyway, so nothing is lost.
|
||||
"""
|
||||
try:
|
||||
if ipaddress.ip_address(destination_ip).version != 4:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
# No packets are sent; this just picks the routing-table source IP.
|
||||
s.connect(("10.255.255.255", 1))
|
||||
s.connect((destination_ip, 1))
|
||||
return s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
except Exception:
|
||||
# Fail soft: this is a diagnostic, and an unroutable address or an
|
||||
# exhausted fd table must leave the check skipped, not 500 the page.
|
||||
return None
|
||||
|
||||
|
||||
def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
|
||||
"""True/False if both are IPv4 literals in the same /24; None if undeterminable."""
|
||||
def _same_subnet(printer_ip: str, host_ip: str) -> bool | None:
|
||||
"""Is ``printer_ip`` inside the network configured on Bambuddy's ``host_ip``?
|
||||
|
||||
None means undeterminable — a name instead of an IPv4 literal, or no
|
||||
local interface claiming ``host_ip``.
|
||||
|
||||
An address does not carry its prefix, and this used to supply ``/24`` for
|
||||
both sides. That is the most common LAN and not the only one: on the
|
||||
reporter's ``192.168.96.0/22`` it declared a printer four hundred
|
||||
addresses away to be on a different network and told him to go configure
|
||||
routing between two halves of one subnet (#3092). The prefix is read off
|
||||
the interface that owns the source address instead.
|
||||
"""
|
||||
try:
|
||||
addr_a = ipaddress.ip_address(ip_a)
|
||||
addr_b = ipaddress.ip_address(ip_b)
|
||||
printer_addr = ipaddress.ip_address(printer_ip)
|
||||
host_addr = ipaddress.ip_address(host_ip)
|
||||
except ValueError:
|
||||
return None
|
||||
if addr_a.version != 4 or addr_b.version != 4:
|
||||
if printer_addr.version != 4 or host_addr.version != 4:
|
||||
return None
|
||||
net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
|
||||
net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
|
||||
return net_a == net_b
|
||||
|
||||
network = find_local_ipv4_network(str(host_addr))
|
||||
if network is None:
|
||||
return None
|
||||
return printer_addr in network
|
||||
|
||||
|
||||
# macOS attributes Local Network permission (TCC) to a process's code
|
||||
# signature, and judges a launchd-spawned process on its own instead of
|
||||
# letting it inherit the grant of the Terminal that started it. Homebrew's
|
||||
# Python is unsigned on Intel, so there is no identity for a grant to attach
|
||||
# to: every connection to a LAN address is dropped, with no error the
|
||||
# application can log and no permission prompt. All three printer ports read
|
||||
# as unreachable while the subnet check passes (#3114).
|
||||
_CODESIGN = "/usr/bin/codesign"
|
||||
# Reading a local file's signature takes milliseconds, so this is a guard
|
||||
# rather than a budget -- and it is deliberately short. The support bundle
|
||||
# gives each printer 15s total (_PER_DIAGNOSTIC_TIMEOUT_SECONDS) and drops
|
||||
# the whole connection diagnostic on overrun, so a codesign that hangs (the
|
||||
# stub that offers to install the command line tools is the plausible way)
|
||||
# must not be able to cost the bundle the rest of its checks.
|
||||
_CODESIGN_TIMEOUT = 2.0
|
||||
|
||||
|
||||
def _base_interpreter_path() -> str:
|
||||
"""The interpreter macOS judges, as both the probe and the message see it.
|
||||
|
||||
``sys._base_executable`` rather than ``sys.executable``: inside a venv the
|
||||
latter is a symlink in the venv's own bin directory, and what macOS judges
|
||||
is the real interpreter it resolves to. Resolved once, here, so the path
|
||||
reported to the user is the same one whose signature was read.
|
||||
"""
|
||||
return os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable)
|
||||
|
||||
|
||||
def _interpreter_is_signed() -> bool | None:
|
||||
"""Does the interpreter Bambuddy runs under carry a code signature?
|
||||
|
||||
None when it cannot be told: no usable ``codesign`` because the Xcode
|
||||
command line tools are absent, or the probe failed some other way. That
|
||||
is deliberately not folded into False. The advice for "no identity" names
|
||||
a repair that rewrites a file inside the user's Python installation, and
|
||||
offering that on a guess is worse than giving the generic answer.
|
||||
|
||||
On an Apple Silicon Homebrew install the interpreter resolves to the
|
||||
framework's ``bin/pythonX.Y`` (measured, inside and outside a venv alike)
|
||||
-- not the ``Python.app`` stub, which is a separate binary in the same
|
||||
framework. The reporter's TCC log names the same ``bin/pythonX.Y`` on
|
||||
Intel.
|
||||
"""
|
||||
executable = _base_interpreter_path()
|
||||
if not executable:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_CODESIGN, "-d", executable],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_CODESIGN_TIMEOUT,
|
||||
)
|
||||
except Exception:
|
||||
# Fail soft, as everywhere else in this module: a diagnostic that
|
||||
# raises is worse than one that declines to answer.
|
||||
logger.debug("codesign probe failed", exc_info=True)
|
||||
return None
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
# codesign writes this to stderr and exits non-zero. It is the one
|
||||
# outcome that separates "no identity at all" from "the probe never ran".
|
||||
if "not signed at all" in result.stderr:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
async def run_connection_diagnostic(
|
||||
@@ -292,19 +451,67 @@ async def run_connection_diagnostic(
|
||||
)
|
||||
)
|
||||
|
||||
# --- Docker network mode ---
|
||||
# --- macOS Local Network permission ---
|
||||
# Appended on macOS only. Everywhere else there is nothing to say, and a
|
||||
# permanently dimmed "skipped" row would be noise for the users who make
|
||||
# up nearly all of them.
|
||||
#
|
||||
# Both outcomes are reported as warn rather than fail, and only when the
|
||||
# control port is already unreachable -- so this can never be the check
|
||||
# that turns an otherwise healthy result red. A printer that is simply
|
||||
# switched off produces the same all-ports-dead pattern, which is why the
|
||||
# signature probe, not the pattern, is what earns the specific advice.
|
||||
if sys.platform == "darwin":
|
||||
if mqtt_ok:
|
||||
# The control port answered, so LAN access demonstrably works.
|
||||
checks.append(DiagnosticCheck(id="macos_local_network", status="pass"))
|
||||
else:
|
||||
signed = await asyncio.to_thread(_interpreter_is_signed)
|
||||
if signed is False:
|
||||
checks.append(
|
||||
DiagnosticCheck(
|
||||
id="macos_local_network",
|
||||
status="warn",
|
||||
params={"reason": "unsigned", "executable": _base_interpreter_path()},
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Signed, or undeterminable. An ad-hoc signature -- which is
|
||||
# what every arm64 binary carries, because the linker adds one
|
||||
# -- identifies itself by a hash of the binary, so a Python
|
||||
# upgrade presents macOS with a new application and leaves the
|
||||
# old grant behind. That is repairable in System Settings,
|
||||
# unlike the unsigned case, so point there instead.
|
||||
checks.append(DiagnosticCheck(id="macos_local_network", status="warn", params={"reason": "permission"}))
|
||||
|
||||
# --- Container network mode ---
|
||||
# Not Docker-only: Podman runs Bambuddy in exactly the same two shapes and
|
||||
# its users were told "Not running in Docker", which reads as "you are on
|
||||
# bare metal" and sent them looking for the problem somewhere else (#3092).
|
||||
runtime = detect_container_runtime()
|
||||
network_mode: str | None = None
|
||||
if is_running_in_docker():
|
||||
network_mode = _detect_docker_network_mode()
|
||||
if runtime is None:
|
||||
checks.append(DiagnosticCheck(id="network_mode", status="skip"))
|
||||
elif runtime not in OCI_RUNTIMES:
|
||||
# An LXC/LXD system container is bridged onto the LAN like a small VM.
|
||||
# There is no network mode to recommend, so don't imply there is one.
|
||||
checks.append(
|
||||
DiagnosticCheck(
|
||||
id="network_mode",
|
||||
status="pass" if network_mode == "host" else "warn",
|
||||
params={"mode": network_mode},
|
||||
)
|
||||
DiagnosticCheck(id="network_mode", status="skip", params={"reason": "system_container", "runtime": runtime})
|
||||
)
|
||||
else:
|
||||
checks.append(DiagnosticCheck(id="network_mode", status="skip"))
|
||||
network_mode = _detect_container_network_mode(runtime)
|
||||
if network_mode is None:
|
||||
checks.append(
|
||||
DiagnosticCheck(id="network_mode", status="skip", params={"reason": "unknown", "runtime": runtime})
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
DiagnosticCheck(
|
||||
id="network_mode",
|
||||
status="pass" if network_mode == "host" else "warn",
|
||||
params={"mode": network_mode, "runtime": runtime},
|
||||
)
|
||||
)
|
||||
|
||||
# --- Subnet match ---
|
||||
# Skipped in bridge mode: the container IP is the bridge IP, not the host's,
|
||||
@@ -312,8 +519,9 @@ async def run_connection_diagnostic(
|
||||
if network_mode == "bridge":
|
||||
checks.append(DiagnosticCheck(id="subnet", status="skip"))
|
||||
else:
|
||||
host_ip = _get_host_ip()
|
||||
same = _same_subnet(ip_address, host_ip) if host_ip else None
|
||||
host_ip = _host_source_ip(ip_address)
|
||||
# Off the loop: resolving the prefix shells out to `ip -j addr show`.
|
||||
same = await asyncio.to_thread(_same_subnet, ip_address, host_ip) if host_ip else None
|
||||
if same is None:
|
||||
checks.append(DiagnosticCheck(id="subnet", status="skip"))
|
||||
else:
|
||||
@@ -393,7 +601,7 @@ async def run_connection_diagnostic(
|
||||
):
|
||||
# The toggle is on, a card is in, the printer said the last print's file
|
||||
# is on internal storage — and a probe confirmed it really is out of
|
||||
# reach. That is what H2-series and P2S firmware does, and no setting
|
||||
# reach. That is what H2-series, P2S and X2D firmware does, and no setting
|
||||
# here changes it (#2762 tracks reading that storage). A pass here would
|
||||
# be a lie; a fail would be unresolvable.
|
||||
#
|
||||
|
||||
@@ -15,6 +15,7 @@ from backend.app.services.bambu_mqtt import (
|
||||
PrinterState,
|
||||
get_stage_name,
|
||||
)
|
||||
from backend.app.utils.ams_humidity import ams_humidity_percent
|
||||
from backend.app.utils.kprofile_lookup import build_slot_k_resolver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -187,24 +188,6 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
|
||||
return model_upper in STG_CUR_IDLE_BUG_MODELS
|
||||
|
||||
|
||||
def is_bed_slinger(model: str | None) -> bool:
|
||||
"""Whether the printer's Z axis controls the *toolhead*, not the bed.
|
||||
|
||||
Bambu's A1 family (A1, A1 Mini; internal codes N1 / N2S) are open-frame
|
||||
bed-slingers: the bed moves on Y, the toolhead moves on X+Z. On every
|
||||
other current model (X1, P1, H2, H2C, H2D, H2S, P2S, ...) the bed moves
|
||||
on Z and the toolhead is fixed in Z.
|
||||
|
||||
G-code direction is opposite on these two families. `G1 Z-10` reduces
|
||||
the nozzle-bed gap on both, but on bed-on-Z machines it does so by
|
||||
moving the BED up, while on bed-slingers it does so by moving the
|
||||
TOOLHEAD down — which is what crashed the nozzle in #1334.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
return model.strip().upper() in A1_MODELS
|
||||
|
||||
|
||||
# Minimum firmware versions for AMS drying support (confirmed via capture testing)
|
||||
# Keys are exact model names (upper-cased). Do NOT use substring matching — it would
|
||||
# incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
|
||||
@@ -1417,22 +1400,10 @@ def printer_state_to_dict(
|
||||
"exists": tray.get("exists"),
|
||||
}
|
||||
)
|
||||
# Prefer humidity_raw (actual percentage) over humidity (index 1-5)
|
||||
humidity_raw = ams_data.get("humidity_raw")
|
||||
humidity_idx = ams_data.get("humidity")
|
||||
humidity_value = None
|
||||
|
||||
if humidity_raw is not None:
|
||||
try:
|
||||
humidity_value = int(humidity_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip unparseable humidity; will try index fallback
|
||||
# Fall back to index if no raw value (index is 1-5, not percentage)
|
||||
if humidity_value is None and humidity_idx is not None:
|
||||
try:
|
||||
humidity_value = int(humidity_idx)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip unparseable humidity index; humidity remains None
|
||||
# Percentage only — the 1-5 index is inverted and must never stand
|
||||
# in for one (#3140). See utils/ams_humidity.
|
||||
humidity_pct = ams_humidity_percent(ams_data)
|
||||
humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
|
||||
|
||||
# AMS-HT has 1 tray, regular AMS has 4 trays
|
||||
is_ams_ht = len(trays) == 1
|
||||
|
||||
@@ -14,10 +14,13 @@ we don't need to thread the user's profile triplet through here. That choice
|
||||
also protects the numbers — overriding the process preset drops the project's
|
||||
own support configuration, which loses whole slots from the answer.
|
||||
|
||||
The one thing that can defeat those embedded settings is a custom G-code
|
||||
template written by a Studio newer than the sidecar, which fails to parse
|
||||
before any slice_info exists. That case gets one retry with the offending
|
||||
template blanked; see ``_blank_custom_gcode``.
|
||||
Two things can defeat those embedded settings. A custom G-code template
|
||||
written by a Studio newer than the sidecar fails to parse before any
|
||||
slice_info exists; that case gets one retry with the offending template
|
||||
blanked, see ``_blank_custom_gcode``. And Bambu Studio writes inherit/unset
|
||||
markers into ``project_settings.config`` that some slicer builds' range
|
||||
validator rejects outright, so the same sanitiser the real slice runs is
|
||||
applied here too, see ``sanitize_project_settings_sentinels``.
|
||||
|
||||
Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
|
||||
repeat opens on the same plate are instant. LRU eviction keeps the cache
|
||||
@@ -42,6 +45,7 @@ from backend.app.services.slicer_api import (
|
||||
SlicerApiError,
|
||||
SlicerApiService,
|
||||
)
|
||||
from backend.app.utils.threemf_tools import sanitize_project_settings_sentinels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -203,13 +207,17 @@ async def get_preview_filaments(
|
||||
|
||||
Uses the file's embedded settings (``slice_without_profiles``) since the
|
||||
slot mapping is a model property, independent of any user-picked profile
|
||||
triplet. A slice killed by an unparsable custom G-code template is retried
|
||||
once with that template blanked, still on the file's own settings.
|
||||
triplet. Those settings are sentinel-sanitised first (#1201, #3030). A
|
||||
slice killed by an unparsable custom G-code template is retried once with
|
||||
that template blanked, still on the file's own settings.
|
||||
|
||||
Returns ``None`` when the preview slice fails — the caller should fall
|
||||
back to whatever heuristic it has (typically the project_filaments +
|
||||
painted-face approach in ``threemf_tools``).
|
||||
"""
|
||||
# Hash the file as it was given to us, not as it is sent: the key
|
||||
# identifies the source file, and sanitising is deterministic, so folding
|
||||
# it in would only make two names for one thing.
|
||||
h = _content_hash(file_bytes)
|
||||
key: _PreviewCacheKey = (kind, source_id, plate_id, h)
|
||||
cached = _preview_cache.get(key)
|
||||
@@ -231,6 +239,17 @@ async def get_preview_filaments(
|
||||
# while the slicer is visibly working.
|
||||
svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
|
||||
|
||||
# Same sanitiser the real slice runs (#1201, #3030). It matters more
|
||||
# here, not less: this path slices on the file's own embedded
|
||||
# settings, so there is no --load-settings pass that could supply a
|
||||
# replacement for a field the CLI's range validator has already
|
||||
# rejected. Without it a MakerWorld 3MF carrying Bambu's inherit
|
||||
# markers fails before producing any slice_info, and the modal falls
|
||||
# back to its painted-face heuristic for a file the slicer could have
|
||||
# answered exactly. Applied before the G-code retry below so that
|
||||
# retry inherits it rather than reintroducing the markers.
|
||||
slice_bytes = sanitize_project_settings_sentinels(file_bytes)
|
||||
|
||||
async def _slice(model_bytes: bytes):
|
||||
async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
|
||||
return await svc.slice_without_profiles(
|
||||
@@ -242,7 +261,7 @@ async def get_preview_filaments(
|
||||
)
|
||||
|
||||
try:
|
||||
result = await _slice(file_bytes)
|
||||
result = await _slice(slice_bytes)
|
||||
except SlicerApiError as e:
|
||||
# One retry, and only for a custom-G-code template the sidecar
|
||||
# cannot parse — a file from a Studio newer than the sidecar. The
|
||||
@@ -259,7 +278,7 @@ async def get_preview_filaments(
|
||||
retry_bytes = None
|
||||
option = _unparsable_gcode_option(str(e))
|
||||
if option is not None:
|
||||
retry_bytes = _blank_custom_gcode(file_bytes, option)
|
||||
retry_bytes = _blank_custom_gcode(slice_bytes, option)
|
||||
if retry_bytes is None:
|
||||
logger.warning(
|
||||
"Preview slice failed for %s/%s plate %s: %s",
|
||||
|
||||
@@ -35,10 +35,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
@@ -49,6 +51,65 @@ from backend.app.utils.filament_types import is_material_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Orca Cloud profile ids are UUIDs, the one preset reference in this codebase
|
||||
# with no letter prefix to key off. A spool stores the bare id (the spool form
|
||||
# persists ``preset.setting_id`` verbatim), so shape is all there is to go on.
|
||||
_ORCA_PROFILE_ID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||
|
||||
|
||||
async def _orca_filament_id(
|
||||
db: AsyncSession,
|
||||
current_user: User | None,
|
||||
profile_id: str,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""Look up an Orca Cloud profile's own filament_id.
|
||||
|
||||
Returns ``(filament_id, name, filament_type)`` -- all empty/None when the
|
||||
profile cannot be fetched or carries no id of its own, which leaves the
|
||||
caller on its generic fallback.
|
||||
|
||||
Best-effort by construction: this runs inside spool assignment, not a user
|
||||
request, so a missing pairing, a revoked token or a lapsed permission must
|
||||
degrade to the fallback rather than fail the assignment. That is also why
|
||||
``clear_on_auth_failure=False`` -- Orca reports every refresh rejection with
|
||||
one composite reason, so a background caller cannot tell a real revocation
|
||||
from a lost rotation race and must not wipe a working pairing on it. The
|
||||
route path hits the same failure in front of a user and clears there.
|
||||
"""
|
||||
if current_user is not None and not current_user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
|
||||
logger.debug("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
|
||||
return ("", None, None)
|
||||
|
||||
svc = None
|
||||
try:
|
||||
from backend.app.api.routes.orca_cloud import _build_authenticated_service
|
||||
|
||||
svc = await _build_authenticated_service(db, current_user, clear_on_auth_failure=False)
|
||||
profile = await svc.get_profile(profile_id)
|
||||
except Exception as e:
|
||||
logger.debug("Orca filament lookup failed for %r: %s", profile_id, e)
|
||||
return ("", None, None)
|
||||
finally:
|
||||
# A raise in `finally` escapes the `except` above, so guard it: closing
|
||||
# an httpx client must never be what fails a spool assignment.
|
||||
if svc is not None:
|
||||
try:
|
||||
await svc.close()
|
||||
except Exception as e: # noqa: BLE001 - close() is best-effort
|
||||
logger.debug("Orca client close failed after lookup of %r: %s", profile_id, e)
|
||||
|
||||
content = profile.get("content") if isinstance(profile, dict) else None
|
||||
if not isinstance(content, dict):
|
||||
return ("", None, None)
|
||||
raw_fid = content.get("filament_id")
|
||||
filament_id = raw_fid.strip() if isinstance(raw_fid, str) else ""
|
||||
name = profile.get("name") if isinstance(profile, dict) else None
|
||||
return (
|
||||
filament_id,
|
||||
name if isinstance(name, str) and name else None,
|
||||
_preset_filament_type(content.get("filament_type")),
|
||||
)
|
||||
|
||||
|
||||
def _preset_filament_type(raw: object) -> str | None:
|
||||
"""Read a slicer preset's ``filament_type`` field.
|
||||
@@ -129,7 +190,26 @@ async def resolve_slicer_filament(
|
||||
# All three need a cloud-detail lookup to extract the underlying
|
||||
# filament_id; without it the raw cloud id ends up in tray_info_idx
|
||||
# and the printer's calibration table can't resolve it.
|
||||
if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
|
||||
# Source order is Orca Cloud, Bambu Cloud, local import, generic fallback.
|
||||
# Orca goes first because its ids are the only ones identified by shape
|
||||
# rather than prefix -- and because, before #3003, a UUID fell through every
|
||||
# branch below into ``normalize_slicer_filament``, which passes anything it
|
||||
# does not recognise straight through. A 36-character UUID then went into
|
||||
# tray_info_idx, an 8-character field, and the slot ended up pointing at the
|
||||
# first 8 characters of a UUID: the same failure the PFUS guard at the
|
||||
# bottom of this function exists for.
|
||||
if _ORCA_PROFILE_ID.fullmatch(base_sf):
|
||||
tray_info_idx, orca_name, orca_type = await _orca_filament_id(db, current_user, base_sf)
|
||||
if orca_type:
|
||||
type_override = orca_type
|
||||
if orca_name:
|
||||
sub_brand_override = orca_name.split("@")[0].strip()
|
||||
# setting_id is left empty here: the UUID is what the slicer cannot
|
||||
# resolve, and unlike a PFUS there is no cloud id form it accepts
|
||||
# instead. All three callers then derive one from the filament_id
|
||||
# (`filament_id_to_setting_id`), which is what keeps the slot from
|
||||
# going out half configured -- the same path a local import takes.
|
||||
elif base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
|
||||
setting_id = base_sf
|
||||
try:
|
||||
from backend.app.api.routes.cloud import build_authenticated_cloud
|
||||
@@ -147,8 +227,30 @@ async def resolve_slicer_filament(
|
||||
type_override = _preset_filament_type(
|
||||
(cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
|
||||
)
|
||||
if detail.get("filament_id"):
|
||||
tray_info_idx = detail["filament_id"]
|
||||
# A custom preset's OWN filament_id is the only thing that
|
||||
# gets it into an AMS slot as itself: the printer stores
|
||||
# that id, the slicer matches its presets against it, and
|
||||
# the 8-character field fits it exactly ("P" + 7 hex).
|
||||
#
|
||||
# Bambu Cloud normally returns it on the envelope, which is
|
||||
# what the captures in #1053 show for a Studio-created
|
||||
# preset (filament_id: "Pbd31b30"). The `setting` fallback
|
||||
# here is belt-and-braces for a response that carries it in
|
||||
# the preset JSON instead, the same spread `filament_type`
|
||||
# above has to handle -- no captured response has needed it
|
||||
# yet, and it costs a dict lookup to be ready for one.
|
||||
#
|
||||
# An Orca-created preset has no filament_id anywhere: the
|
||||
# envelope says null and `setting` is a delta from the base
|
||||
# (#1053 again). Those legitimately fall to base_id below
|
||||
# and reach the slicer as the profile they inherit from --
|
||||
# an OrcaSlicer preset-format gap, filed upstream as
|
||||
# OrcaSlicer PR #13315, not something resolvable here.
|
||||
own_filament_id = detail.get("filament_id") or (
|
||||
cloud_setting.get("filament_id") if isinstance(cloud_setting, dict) else None
|
||||
)
|
||||
if own_filament_id:
|
||||
tray_info_idx = own_filament_id
|
||||
cloud_name = detail.get("name", "")
|
||||
if cloud_name:
|
||||
sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
|
||||
@@ -245,10 +347,16 @@ async def resolve_slicer_filament(
|
||||
# the original assign.
|
||||
# 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
|
||||
# "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
|
||||
# 4. Orca Cloud profile UUIDs, when the branch above could not reach the
|
||||
# profile to trade one for its filament_id (#3003). Worst of the four
|
||||
# at 36 characters against an 8-character field.
|
||||
# Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
|
||||
# "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
|
||||
if tray_info_idx and (
|
||||
is_material_name(tray_info_idx) or tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")
|
||||
is_material_name(tray_info_idx)
|
||||
or tray_info_idx.startswith("PFUS")
|
||||
or tray_info_idx.startswith("PFCN")
|
||||
or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
|
||||
):
|
||||
tray_info_idx = ""
|
||||
# Preserve setting_id when it's still a valid slicer reference
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""The one answer both inventory modes give when a tag is already taken.
|
||||
|
||||
Linking an RFID tag lives in two routes -- ``inventory.py`` for the built-in
|
||||
inventory and ``spoolman_inventory.py`` for Spoolman mode -- and they used to
|
||||
refuse a duplicate with two different sentences, only one of which named the
|
||||
spool holding the tag (#3110). A client cannot act on prose, so both now raise
|
||||
the structured detail built here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Which identifier collided: separate columns on a built-in spool, separate
|
||||
# lengths inside Spoolman's ``extra.tag``. A client that offers to move the tag
|
||||
# needs to know which of the two it is moving.
|
||||
TagField = Literal["tag_uid", "tray_uuid"]
|
||||
|
||||
_FIELD_LABELS: dict[TagField, str] = {"tag_uid": "Tag UID", "tray_uuid": "Tray UUID"}
|
||||
|
||||
|
||||
def tag_already_linked(field: TagField, holder_id: int) -> HTTPException:
|
||||
"""409 naming the active spool that already carries this tag.
|
||||
|
||||
The frontend renders the user-facing message via i18n on ``code``;
|
||||
``message`` is an English fallback for non-UI clients (curl / scripts).
|
||||
``holder_id`` is what lets a caller offer to move the tag rather than only
|
||||
report that it is taken.
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"code": "tag_already_linked",
|
||||
"message": f"{_FIELD_LABELS[field]} is already linked to spool {holder_id}",
|
||||
"spool_id": holder_id,
|
||||
"field": field,
|
||||
},
|
||||
)
|
||||
@@ -1,7 +1,8 @@
|
||||
"""TLS certificate generation for virtual printer services.
|
||||
|
||||
Generates certificates that mimic real Bambu printer certificate format:
|
||||
- CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
|
||||
Generates the certificate chain a slicer accepts in place of a real printer's:
|
||||
- CA certificate with CN = "Virtual Printer CA <id>", unique to the install
|
||||
that generated it (a CA generated before that carries the bare name)
|
||||
- Printer certificate has CN = serial number, signed by the CA
|
||||
|
||||
The CA certificate is persistent and only regenerated if missing or expired.
|
||||
@@ -27,6 +28,11 @@ DEFAULT_SERIAL = "00M09A391800001"
|
||||
# Minimum days remaining before CA is considered expired and needs regeneration
|
||||
CA_EXPIRY_THRESHOLD_DAYS = 30
|
||||
|
||||
# Common-name prefix of the generated CA. What follows it is derived from the
|
||||
# CA's own public key, so two installs never share a Subject DN -- see
|
||||
# ``_generate_ca_certificate`` for why that matters.
|
||||
CA_COMMON_NAME_PREFIX = "Virtual Printer CA"
|
||||
|
||||
|
||||
def _get_local_ip() -> str:
|
||||
"""Get the local IP address."""
|
||||
@@ -43,8 +49,10 @@ def _get_local_ip() -> str:
|
||||
class CertificateService:
|
||||
"""Generate and manage TLS certificates for virtual printer.
|
||||
|
||||
Creates a certificate chain mimicking real Bambu printers:
|
||||
- Root CA with CN="BBL CA", O="BBL Technologies Co., Ltd", C="CN"
|
||||
Creates a certificate chain a slicer accepts in place of a real
|
||||
printer's:
|
||||
- Root CA with CN="Virtual Printer CA <id>", unique to the install that
|
||||
generated it (an older CA carries the bare name and is kept as it is)
|
||||
- Printer cert with CN=serial_number, signed by the CA
|
||||
"""
|
||||
|
||||
@@ -90,9 +98,10 @@ class CertificateService:
|
||||
is broken even though both files exist on disk. ``ensure_certificates``
|
||||
uses this to decide whether to regenerate.
|
||||
|
||||
Uses real signature verification — Bambuddy's auto-generated CAs all
|
||||
share the same Subject DN ("Virtual Printer CA"), so a DN-only compare
|
||||
would incorrectly return True even after rotation.
|
||||
Uses real signature verification — every CA generated before the
|
||||
common name carried a per-install suffix is literally
|
||||
"CN=Virtual Printer CA", so on those installs a DN-only compare would
|
||||
incorrectly return True even after rotation.
|
||||
"""
|
||||
try:
|
||||
if not self.ca_cert_path.exists():
|
||||
@@ -213,10 +222,25 @@ class CertificateService:
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Use a generic CA name - NOT BBL to avoid being rejected as fake
|
||||
# Use a generic CA name - NOT BBL to avoid being rejected as fake.
|
||||
#
|
||||
# The name carries a per-install suffix taken from this CA's own key
|
||||
# identifier. A slicer trust store is a flat list of certificates and
|
||||
# OpenSSL looks an issuer up by Subject DN: it takes the first CA whose
|
||||
# DN matches and fails the chain if that one did not sign the
|
||||
# certificate, rather than trying the next match. So while every
|
||||
# install signed as plain "CN=Virtual Printer CA", a user who imported
|
||||
# the CAs of two Bambuddy instances broke one of them — each worked on
|
||||
# its own, together whichever landed second in the file lost, with the
|
||||
# same generic connection error an unimported CA gives (#3014).
|
||||
# Distinct DNs mean both are found and both verify.
|
||||
ca_skid = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key())
|
||||
ca_name = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA"),
|
||||
x509.NameAttribute(
|
||||
NameOID.COMMON_NAME,
|
||||
f"{CA_COMMON_NAME_PREFIX} {ca_skid.digest.hex()[:8].upper()}",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -248,6 +272,7 @@ class CertificateService:
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
.add_extension(ca_skid, critical=False)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
@@ -313,12 +338,23 @@ class CertificateService:
|
||||
# Issuer is the CA
|
||||
issuer = ca_cert.subject
|
||||
|
||||
# Key identifiers, but only when the CA carries one to point at. A CA
|
||||
# generated before the per-install common name has no
|
||||
# SubjectKeyIdentifier, and a leaf signed by it keeps exactly the shape
|
||||
# it has today rather than naming an identifier its issuer does not
|
||||
# advertise — those installs keep working with the CA they imported
|
||||
# long ago, untouched.
|
||||
try:
|
||||
ca_skid = ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
|
||||
except x509.ExtensionNotFound:
|
||||
ca_skid = None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
local_ip = _get_local_ip()
|
||||
logger.info("Generating printer certificate with CN=%s, local IP: %s", self.serial, local_ip)
|
||||
|
||||
# Build printer certificate signed by CA
|
||||
printer_cert = (
|
||||
printer_cert_builder = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(printer_subject)
|
||||
.issuer_name(issuer)
|
||||
@@ -357,9 +393,19 @@ class CertificateService:
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
.sign(ca_key, hashes.SHA256()) # Signed by CA, not self-signed
|
||||
)
|
||||
|
||||
if ca_skid is not None:
|
||||
printer_cert_builder = printer_cert_builder.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(printer_key.public_key()),
|
||||
critical=False,
|
||||
).add_extension(
|
||||
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ca_skid),
|
||||
critical=False,
|
||||
)
|
||||
|
||||
printer_cert = printer_cert_builder.sign(ca_key, hashes.SHA256()) # Signed by CA, not self-signed
|
||||
|
||||
# Write printer private key
|
||||
self.key_path.write_bytes(
|
||||
printer_key.private_bytes(
|
||||
@@ -380,7 +426,7 @@ class CertificateService:
|
||||
self.cert_path.write_bytes(cert_chain)
|
||||
|
||||
logger.info("Generated certificate chain at %s", self.cert_dir)
|
||||
logger.info(" CA: CN=Virtual Printer CA")
|
||||
logger.info(" CA: %s", ca_cert.subject.rfc4514_string())
|
||||
logger.info(" Printer: CN=%s", self.serial)
|
||||
return self.cert_path, self.key_path
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Shared reading of an AMS unit's humidity.
|
||||
|
||||
Bambu sends two humidity fields and they are not the same quantity.
|
||||
``humidity_raw`` is relative humidity in percent. ``humidity`` is a 1-5 drop
|
||||
index, and it runs the other way: OpenBambuAPI's push_info sample carries both
|
||||
in one line -- ``ams0 temp:18.4;humidity:30%;humidity_idx:4`` -- so a high index
|
||||
means dry where a high percentage means wet.
|
||||
|
||||
Falling back from one to the other therefore does not degrade, it inverts.
|
||||
Index 2 rendered as "2%" reads as the driest a unit can be while the unit is in
|
||||
fact the second-wettest of the five steps, and no index can ever exceed a
|
||||
percentage threshold, so the humidity alarm and auto-drying silently never fire
|
||||
for such a unit (#3140). A unit that reports no percentage has no percentage:
|
||||
this returns ``None``, which every caller already treats as "no reading" -- the
|
||||
card hides the indicator, the alarm and auto-drying skip the unit, and the
|
||||
history chart leaves a gap.
|
||||
|
||||
Kept as a leaf module like ``ams_drying``: nothing here imports from the app.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def ams_humidity_percent(ams_data: Any) -> float | None:
|
||||
"""Relative humidity in percent for one AMS unit, or ``None``.
|
||||
|
||||
``None`` covers every case where the unit did not report a usable
|
||||
percentage, including the units that send only the 1-5 index -- which is
|
||||
deliberately never converted. See the module docstring.
|
||||
"""
|
||||
if not isinstance(ams_data, Mapping):
|
||||
return None
|
||||
raw = ams_data.get("humidity_raw")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None # Unparseable reading — not a licence to use the index
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Which nozzle an AMS slot feeds, with or without a Filament Track Switch.
|
||||
|
||||
K-profiles are per-nozzle, and the printer's calibration tables are numbered
|
||||
per-nozzle too: ``cali_idx: 16`` means "entry 16 of whichever nozzle feeds this
|
||||
tray". Without a switch that is unambiguous, because each AMS is wired to one
|
||||
extruder and says so in its ``info`` bits. With a switch installed every AMS
|
||||
reports 0xE instead and is bound to a switch *inlet*, so the answer has to come
|
||||
from the inlet binding.
|
||||
K-profiles are per-nozzle: a calibration run belongs to the hotend it ran on,
|
||||
and ``cali_idx: 16`` can name a different profile on each. (It need not — one
|
||||
profile can also be what both extruders' slots point at, which is why the K
|
||||
lookup in ``kprofile_lookup`` treats the extruder as a preference rather than a
|
||||
filter — but the routing question below is the same either way.) Without a
|
||||
switch the answer is unambiguous, because each AMS is wired to one extruder and
|
||||
says so in its ``info`` bits. With a switch installed every AMS reports 0xE
|
||||
instead and is bound to a switch *inlet*, so the answer has to come from the
|
||||
inlet binding.
|
||||
|
||||
Every caller that resolves a slot to an extruder should go through
|
||||
``slot_extruder`` here. Three separate copies of that logic used to end in
|
||||
|
||||
@@ -2,21 +2,41 @@
|
||||
|
||||
H2-series trays carry no ``k`` field of their own — only ``cali_idx`` — so the
|
||||
K value on the AMS slot card (#2854) is looked up from the printer's
|
||||
calibration table in ``state.kprofiles``. That table is not flat: the printer
|
||||
numbers it **per nozzle**, so entry 16 exists under every nozzle it holds
|
||||
profiles for and means a different profile on each.
|
||||
calibration table in ``state.kprofiles``.
|
||||
|
||||
``state.kprofiles`` is the union across nozzle diameters (see
|
||||
``BambuMQTTClient._store_kprofiles``), which is what the assign paths need but
|
||||
makes ``cali_idx`` alone ambiguous. Resolution here is therefore:
|
||||
That table is not a clean per-nozzle numbering, and treating it as one is what
|
||||
blanked every slot on a second AMS (#3044). Both of these happen:
|
||||
|
||||
1. the slot's own extruder, which separates the two nozzles of a dual-nozzle
|
||||
machine outright;
|
||||
2. failing that, the diameters currently installed, which separates a live
|
||||
table from one left behind by a nozzle that has since been swapped out.
|
||||
* Two profiles can share a ``cali_idx`` and differ by extruder — measured on
|
||||
the maintainer's H2C, where one spool read 0.018 on the left nozzle and
|
||||
0.020 on the right. Resolving on ``cali_idx`` alone showed the wrong one.
|
||||
* One profile can be what *both* extruders' slots point at. In the #3044
|
||||
capture an X2D's B1 and B3 carried exactly the K values of A4 and A1 — the
|
||||
same entries, tagged with one extruder. Demanding an extruder match left
|
||||
every slot on the right-hand AMS blank.
|
||||
|
||||
If both fail to single out one profile the answer is ``None``. A blank space on
|
||||
the card is a smaller error than confidently printing the other nozzle's number.
|
||||
The two are told apart by whether the table distinguishes extruders *at all*:
|
||||
|
||||
1. a profile filed under the slot's own extruder wins outright;
|
||||
2. if the slot's extruder appears nowhere in the table, its tagging carries no
|
||||
information about this slot, so match on ``cali_idx`` alone — taking the
|
||||
answer only when the candidates agree on one K value, with the diameters
|
||||
currently installed as the tie-break (which separates a live table from one
|
||||
left behind by a nozzle that has since been swapped out).
|
||||
|
||||
The condition on step 2 is what keeps the H2C case fixed. There extruder 0 does
|
||||
hold profiles, so a right-hand slot pointing at an index only the left hotend
|
||||
has is a real miss — the index means entry 16 *of the right nozzle's table*,
|
||||
and the left's entry 16 is a different profile. Falling back there is how the
|
||||
wrong K got shown in the first place.
|
||||
|
||||
BambuStudio is looser still: ``AMSItem.cpp`` fills the same card through
|
||||
``CalibUtils::get_pa_k_n_value_by_cali_idx``, which scans the whole history for
|
||||
a matching ``cali_idx`` and takes the first hit regardless of nozzle.
|
||||
|
||||
If neither step singles out one value the answer is ``None``. A blank space on
|
||||
the card is a smaller error than confidently printing the other nozzle's
|
||||
number.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
@@ -34,6 +54,9 @@ def build_slot_k_resolver(state) -> Callable[[int | None, int, int], float | Non
|
||||
# detects the ambiguity: more than one entry means two nozzles' tables both
|
||||
# claim this index on this extruder.
|
||||
table: dict[tuple[int, int], dict[str, float]] = {}
|
||||
# cali_idx -> [(nozzle_diameter, k)], every extruder together. The fallback
|
||||
# for an index no profile claims on the slot's own extruder.
|
||||
shared: dict[int, list[tuple[str, float]]] = {}
|
||||
for kp in getattr(state, "kprofiles", None) or []:
|
||||
if kp.slot_id is None or not kp.k_value:
|
||||
continue
|
||||
@@ -45,22 +68,45 @@ def build_slot_k_resolver(state) -> Callable[[int | None, int, int], float | Non
|
||||
extruder = int(kp.extruder_id or 0)
|
||||
except (ValueError, TypeError):
|
||||
extruder = 0
|
||||
table.setdefault((extruder, kp.slot_id), {})[str(kp.nozzle_diameter or "")] = k_value
|
||||
nozzle = str(kp.nozzle_diameter or "")
|
||||
table.setdefault((extruder, kp.slot_id), {})[nozzle] = k_value
|
||||
shared.setdefault(kp.slot_id, []).append((nozzle, k_value))
|
||||
|
||||
# Which extruders the table names at all. An extruder missing from this is
|
||||
# one the printer is not filing profiles under, which is what makes the
|
||||
# cali_idx-only fallback safe for it.
|
||||
extruders_filed = {extruder for extruder, _ in table}
|
||||
|
||||
installed = {str(n.nozzle_diameter) for n in (getattr(state, "nozzles", None) or []) if n.nozzle_diameter}
|
||||
|
||||
def _agreed(candidates: list[tuple[str, float]]) -> float | None:
|
||||
"""The one K these candidates describe, or None if they disagree.
|
||||
|
||||
Values rather than entries: two nozzles listing the same number is not
|
||||
an ambiguity, it is the shared profile the fallback exists for.
|
||||
"""
|
||||
values = {k for _, k in candidates}
|
||||
if len(values) == 1:
|
||||
return values.pop()
|
||||
live = {k for nozzle, k in candidates if nozzle in installed}
|
||||
return live.pop() if len(live) == 1 else None
|
||||
|
||||
def resolve(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
|
||||
if cali_idx is None:
|
||||
return None
|
||||
extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
|
||||
# Single-nozzle printers report everything under extruder 0, and that
|
||||
# is also the right default when the routing is simply unknown.
|
||||
by_nozzle = table.get((extruder if extruder is not None else 0, cali_idx))
|
||||
if not by_nozzle:
|
||||
own = extruder if extruder is not None else 0
|
||||
by_nozzle = table.get((own, cali_idx))
|
||||
if by_nozzle:
|
||||
if len(by_nozzle) == 1:
|
||||
return next(iter(by_nozzle.values()))
|
||||
live = [k for nozzle, k in by_nozzle.items() if nozzle in installed]
|
||||
return live[0] if len(live) == 1 else None
|
||||
if own in extruders_filed:
|
||||
return None
|
||||
if len(by_nozzle) == 1:
|
||||
return next(iter(by_nozzle.values()))
|
||||
live = [k for nozzle, k in by_nozzle.items() if nozzle in installed]
|
||||
return live[0] if len(live) == 1 else None
|
||||
candidates = shared.get(cali_idx)
|
||||
return _agreed(candidates) if candidates else None
|
||||
|
||||
return resolve
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Letting go of a paho MQTT client without waiting for its network thread.
|
||||
|
||||
`Client.loop_stop()` is two statements: set `_thread_terminate`, then `join()`
|
||||
the network thread with no timeout. That thread only reads the flag between
|
||||
iterations of `loop_forever`, so it cannot read it while parked inside
|
||||
`reconnect()` -> `_ssl_wrap_socket()` -> `do_handshake()`. paho gives that
|
||||
handshake the connection's keepalive as its socket timeout -- 30s for a printer
|
||||
-- and a socket timeout is per operation, renewed by every byte the peer sends.
|
||||
A broker that still answers on its port but never finishes the handshake
|
||||
therefore holds the join open for as long as it keeps trickling; a silent one
|
||||
still holds it 30s.
|
||||
|
||||
Whoever called `loop_stop()` waits that out, and in Bambuddy that caller is the
|
||||
asyncio thread. #3068: a printer 38 hours offline, still answering on 8883, was
|
||||
picked up by the connection watchdog exactly as intended; the rebuild ended in
|
||||
that join and the process stopped serving HTTP -- UI, API and health check --
|
||||
while staying alive, so the container's `restart: unless-stopped` never fired.
|
||||
#1445 was the same join reached from the add-printer probe.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long a retirement may take before it is worth a line in the support
|
||||
# bundle. A healthy paho thread exits in well under a second.
|
||||
_RETIRE_SLOW_SECONDS = 5.0
|
||||
|
||||
# The callbacks Bambuddy's three MQTT services set between them. Anything else
|
||||
# paho offers is already None because nobody here assigns it.
|
||||
_CALLBACKS = ("on_connect", "on_disconnect", "on_subscribe", "on_message")
|
||||
|
||||
|
||||
def retire_paho_client(client, label: str) -> None:
|
||||
"""Shut *client* down on a thread of its own and return immediately.
|
||||
|
||||
*label* names the connection in logs and in the retirement thread's name,
|
||||
which is where a thread dump from the next stuck one will be read.
|
||||
|
||||
Two things happen inline rather than on that thread:
|
||||
|
||||
- The callbacks are cleared here. Blocking until the network thread was
|
||||
gone is what used to guarantee a client we had let go of could no longer
|
||||
touch our state; with the teardown detached, a zombie that finishes its
|
||||
handshake would auto-reconnect and report itself connected behind its
|
||||
replacement's back.
|
||||
- Nothing else -- not even `disconnect()`, which is cheap enough to run
|
||||
here (it queues a packet and returns) but would be one more thing
|
||||
between the caller and its return for no gain, since the thread starts
|
||||
within microseconds. It still happens and still matters: it is what
|
||||
stops paho's auto-reconnect, and with it the chance of an unacked
|
||||
`project_file` replaying onto a revived session (#1136).
|
||||
"""
|
||||
for attr in _CALLBACKS:
|
||||
try:
|
||||
setattr(client, attr, None)
|
||||
except Exception: # pragma: no cover - paho always allows this
|
||||
pass
|
||||
|
||||
def _teardown() -> None:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.loop_stop()
|
||||
except Exception:
|
||||
pass
|
||||
waited = time.monotonic() - started
|
||||
if waited >= _RETIRE_SLOW_SECONDS:
|
||||
# The stall that used to be the event loop's. Worth saying out
|
||||
# loud: it means this connection is wedged somewhere paho cannot
|
||||
# interrupt, and the next report of it should not have to be
|
||||
# diagnosed from a thread dump again.
|
||||
logger.warning(
|
||||
"[%s] Retiring the old MQTT client took %.0fs (paho's network thread would "
|
||||
"not stop). The connection was replaced anyway.",
|
||||
label,
|
||||
waited,
|
||||
)
|
||||
|
||||
try:
|
||||
threading.Thread(target=_teardown, name=f"mqtt-retire-{label}", daemon=True).start()
|
||||
except RuntimeError as e:
|
||||
# Out of threads entirely, which means the process has larger problems.
|
||||
# Send the DISCONNECT inline anyway -- it is what keeps the abandoned
|
||||
# session from reconnecting and replaying (#1136) -- and leave the
|
||||
# network thread to paho.
|
||||
logger.error("[%s] Could not start the MQTT teardown thread: %s", label, e)
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1767,3 +1767,125 @@ def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> s
|
||||
used.update(_scan_paint(path))
|
||||
break
|
||||
return used
|
||||
|
||||
|
||||
# Keys in ``Metadata/project_settings.config`` that Bambu Studio writes an
|
||||
# "inherit / unset" marker into, mapped to the marker it uses for that key.
|
||||
# The slicer CLI's ``StaticPrintConfig`` validator runs against the embedded
|
||||
# settings *before* ``--load-settings`` overrides apply, so a marker the CLI's
|
||||
# own range check rejects makes it exit non-zero before our profile triplet is
|
||||
# ever consulted.
|
||||
#
|
||||
# There are two markers because there are two conventions, and which one a
|
||||
# given CLI rejects depends on the build:
|
||||
#
|
||||
# "-1" -- inherit from the parent process preset (#1201, MakerWorld P2S
|
||||
# 3MFs). ``raft_first_layer_expansion`` and ``tree_support_wall_count`` are
|
||||
# min 0 in every OrcaSlicer to date, so those still fail on the current
|
||||
# sidecar; ``prime_tower_brim_width`` gained min -1 in Orca 2.4.2 and now
|
||||
# passes there, but not on older builds.
|
||||
#
|
||||
# "0" -- "use the active object/part filament", the default Bambu Studio
|
||||
# writes for the three feature-filament indices (#3030). Bambu Studio and
|
||||
# OrcaSlicer 2.4.0+ both define these min 0, so 0 is legal there; OrcaSlicer
|
||||
# 2.3.x and earlier still used the 1-based scheme (min 1, default 1) and
|
||||
# reject it with ``0 not in range [1.000000,...]``. Sidecar images are
|
||||
# version-tagged, so an install can be pinned to one of those.
|
||||
#
|
||||
# Removing the key rather than rewriting it is what makes this safe on every
|
||||
# build: the CLI then falls back to its own compiled default, which is 0 on
|
||||
# the builds where 0 was legal (so nothing changes) and 1 on the older ones,
|
||||
# which is what "the active filament" means under that scheme.
|
||||
#
|
||||
# Allowlisted (rather than "strip every marker-shaped value") because some
|
||||
# fields legitimately take the marker value -- z_offset, translations, and any
|
||||
# feature index a user really did set to a first filament -- and a blanket
|
||||
# strip would silently corrupt those.
|
||||
#
|
||||
# Add new entries as reports surface: the slicer names the offending field
|
||||
# directly, e.g. ``<field>: <value> not in range [...]``.
|
||||
PROJECT_SETTINGS_SENTINELS: dict[str, str] = {
|
||||
# Reported in #1201 (MakerWorld P2S 3MFs).
|
||||
"raft_first_layer_expansion": "-1",
|
||||
"tree_support_wall_count": "-1",
|
||||
# Known sentinel case from earlier reports, cited in #1201.
|
||||
"prime_tower_brim_width": "-1",
|
||||
# Reported in #3030 (MakerWorld 3MF, OrcaSlicer sidecar).
|
||||
"wall_filament": "0",
|
||||
"sparse_infill_filament": "0",
|
||||
"solid_infill_filament": "0",
|
||||
}
|
||||
|
||||
PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
|
||||
|
||||
|
||||
def _is_sentinel(value: object, sentinel: str) -> bool:
|
||||
"""Does ``value`` carry ``sentinel``, whether stored as text or a number?
|
||||
|
||||
Bambu Studio writes every ``project_settings.config`` value as a string,
|
||||
but a 3MF that has been round-tripped through another tool can carry the
|
||||
same field as a JSON number. ``bool`` is excluded explicitly: it is an
|
||||
``int`` subclass in Python, and ``str(False)`` would otherwise never match
|
||||
anyway -- the exclusion is there so a future numeric sentinel like ``0``
|
||||
cannot be matched by ``False``.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return False
|
||||
if isinstance(value, (str, int)):
|
||||
return str(value) == sentinel
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
|
||||
"""Strip inherit/unset sentinels from a 3MF's ``project_settings.config``
|
||||
so the slicer CLI's range validator accepts the file (#1201, #3030).
|
||||
|
||||
Removes only allowlisted keys (see ``PROJECT_SETTINGS_SENTINELS``) and only
|
||||
when the value is exactly that key's sentinel. The rest of the config --
|
||||
and every other entry in the zip -- is preserved byte-for-byte. Unlike a
|
||||
whole-file strip this leaves ``StaticPrintConfig`` initialisation intact:
|
||||
the file is still present, still parses, and the slicer falls back to the
|
||||
supplied ``--load-settings`` value, or to its own default, for the removed
|
||||
key.
|
||||
|
||||
Returns the original bytes unchanged when no sanitisation is needed (input
|
||||
isn't a valid zip, no ``project_settings.config``, no allowlisted sentinels
|
||||
present, or any other parse failure) so the caller can pass the result on
|
||||
without further checks.
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
|
||||
if PROJECT_SETTINGS_PATH not in zin.namelist():
|
||||
return zip_bytes
|
||||
try:
|
||||
config = json.loads(zin.read(PROJECT_SETTINGS_PATH).decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return zip_bytes
|
||||
if not isinstance(config, dict):
|
||||
return zip_bytes
|
||||
removed = {
|
||||
key: sentinel
|
||||
for key, sentinel in PROJECT_SETTINGS_SENTINELS.items()
|
||||
if _is_sentinel(config.get(key), sentinel)
|
||||
}
|
||||
if not removed:
|
||||
return zip_bytes
|
||||
for key in removed:
|
||||
config.pop(key, None)
|
||||
patched = json.dumps(config)
|
||||
logger.info(
|
||||
"3MF sanitiser: removed inherit sentinels %s - slicer will use its defaults for those keys",
|
||||
sorted(f"{key}={sentinel}" for key, sentinel in removed.items()),
|
||||
)
|
||||
dst = BytesIO()
|
||||
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename == PROJECT_SETTINGS_PATH:
|
||||
zout.writestr(item, patched)
|
||||
else:
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
return dst.getvalue()
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return zip_bytes
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Stand-ins for the ffmpeg subprocess the external-camera paths spawn.
|
||||
|
||||
Both RTSP paths in ``backend.app.services.external_camera`` build an argv and
|
||||
hand it to ``asyncio.create_subprocess_exec``. Tests that care about *what we
|
||||
asked ffmpeg to do* — the SSRF guards, the probe settings — need to see that
|
||||
argv without an ffmpeg binary being involved, so these patch the lookup and the
|
||||
spawn and record the call.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def fake_ffmpeg():
|
||||
"""Pretend ffmpeg is installed, so the paths get as far as building argv."""
|
||||
return patch("backend.app.services.external_camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg")
|
||||
|
||||
|
||||
def spawn_spy(returncode: int | None = 0, stdout: bytes = b"\xff\xd8" + b"\x00" * 200):
|
||||
"""Stand in for the ffmpeg subprocess, recording the argv it was handed.
|
||||
|
||||
The streaming path reads until EOF, so stdout.read returns b"" and the
|
||||
generator finishes immediately — these tests are about whether ffmpeg was
|
||||
launched and with what, not about frame extraction.
|
||||
"""
|
||||
process = MagicMock()
|
||||
process.returncode = returncode
|
||||
process.communicate = AsyncMock(return_value=(stdout, b""))
|
||||
process.stdout.read = AsyncMock(return_value=b"")
|
||||
process.stderr.read = AsyncMock(return_value=b"")
|
||||
process.wait = AsyncMock(return_value=returncode)
|
||||
process.kill = MagicMock()
|
||||
process.terminate = MagicMock()
|
||||
return patch(
|
||||
"backend.app.services.external_camera.asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(return_value=process),
|
||||
)
|
||||
@@ -85,6 +85,44 @@ class TestAMSHistoryAPI:
|
||||
assert data["min_temperature"] == 24.0
|
||||
assert data["max_temperature"] == 26.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_average_of_zero_is_reported_as_zero(
|
||||
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
|
||||
):
|
||||
"""A window whose readings are all 0 has an average of 0, not "no data".
|
||||
|
||||
The response used to test the average for truthiness, so min and max
|
||||
reported 0.0 while the average beside them came back null and the card
|
||||
showed an em dash (#3140). Zero is rare but real -- a warm unit part way
|
||||
through a drying cycle reaches it.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
await ams_history_factory(printer_id=printer.id, humidity=0.0, temperature=0.0)
|
||||
await ams_history_factory(printer_id=printer.id, humidity=0.0, temperature=0.0)
|
||||
|
||||
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["min_humidity"] == 0.0
|
||||
assert data["avg_humidity"] == 0.0
|
||||
assert data["avg_temperature"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_empty_window_still_has_no_average(self, async_client: AsyncClient, printer_factory):
|
||||
"""The one case that genuinely has no answer must stay null."""
|
||||
printer = await printer_factory()
|
||||
|
||||
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["data"] == []
|
||||
assert data["avg_humidity"] is None
|
||||
assert data["avg_temperature"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_ams_history_with_hours_filter(
|
||||
|
||||
@@ -902,6 +902,39 @@ class TestArchivesAPI:
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["filament_used_grams"] == 12.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_items_printed_accepts_zero_for_a_ruined_plate(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""A jam can ruin every part on the plate while the printer still
|
||||
reports success (#3051). The project's completed-items count sums this
|
||||
column, so zero has to be storable, not floored to one.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(printer.id, quantity=4)
|
||||
|
||||
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": 0})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["quantity"] == 0
|
||||
await db_session.refresh(archive)
|
||||
assert archive.quantity == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_items_printed_refuses_a_negative_count(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory
|
||||
):
|
||||
"""Zero means "nothing came off the plate"; below that would subtract
|
||||
from the project totals this column feeds."""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(printer.id, quantity=4)
|
||||
|
||||
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": -1})
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_archive_failure_reason_mirrors_to_print_log_entry(
|
||||
@@ -1505,6 +1538,106 @@ class TestNo3MFWarningReason:
|
||||
|
||||
assert response.json() == {"has_fallback": False, "reason": None}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_refused_handshake_is_reported(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""#2957 stamps this slug and #2780 never taught the banner about it, so
|
||||
an install whose only empty archives came from a printer refusing TLS on
|
||||
port 990 was told the slicer had not written the file to the card. The
|
||||
slicer had; nothing could read it back. The reporter could see the file
|
||||
on the stick from his own computer, which is exactly why the advice read
|
||||
as Bambuddy being broken.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"},
|
||||
)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_refused_handshake_outranks_every_settled_cause(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""The other three describe an install working as configured. This one
|
||||
reports a printer doing something we cannot yet explain, and the banner
|
||||
dismisses one-shot into localStorage -- so a reason ranked below another
|
||||
is not deferred, it is never shown to that user at all.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
for reason in ("internal_storage", "no_external_storage", "internal_history"):
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": reason})
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"})
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_settled_causes_keep_their_order_behind_it(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Adding a new leader must not disturb the ranking underneath it: an
|
||||
install with no refused handshake still gets exactly what it got before.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_history"})
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"})
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": True, "reason": "no_external_storage"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_transfer_that_ran_out_of_time_is_reported(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""#3063's P1S had the file on its card and served it three times in the
|
||||
two minutes after the archive flow gave up on it. Told to switch on
|
||||
"Store sent files on external storage", that reporter would be switching
|
||||
on a setting that was already on and had already worked.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
extra_data={"no_3mf_available": True, "no_3mf_reason": "ftp_transfer_failed"},
|
||||
)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": True, "reason": "ftp_transfer_failed"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_slow_transfer_outranks_the_settled_causes_but_not_a_refused_handshake(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Both temporary causes describe a file still sitting on the card, so
|
||||
both outrank the three that describe an install working as configured.
|
||||
Between the two, a printer that will not complete a TLS handshake is the
|
||||
worse fault and keeps the banner.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
for reason in ("internal_storage", "no_external_storage", "internal_history"):
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": reason})
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftp_transfer_failed"})
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
assert response.json() == {"has_fallback": True, "reason": "ftp_transfer_failed"}
|
||||
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"})
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
|
||||
|
||||
|
||||
class TestPrintLogEntryDelete:
|
||||
"""#1687: per-row delete on the Print Log page.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""The backup says which version made it, and restore refuses one it cannot import.
|
||||
|
||||
Restoring a backup into a different version of Bambuddy is ordinary -- an
|
||||
upgrade, a rebuild, a move to another host. What is not ordinary is the Postgres
|
||||
restore path, which throws the backup's schema away and rebuilds from the
|
||||
running ORM. A NOT NULL column the running version has and the backup does not
|
||||
then has nothing to put in it, and the import fails on the INSERT: after the
|
||||
drop, with the install's data already gone.
|
||||
|
||||
So the incompatibility has to be found before any of that, and it has to say
|
||||
something an operator can act on. A column name does not; a pair of version
|
||||
numbers does, which is what the manifest is for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.core.config import APP_VERSION, settings as app_settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_backup_records_the_version_that_made_it(async_client, monkeypatch, tmp_path):
|
||||
from backend.app.api.routes.settings import create_backup_zip
|
||||
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
|
||||
|
||||
zip_path, _filename = await create_backup_zip(output_path=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
assert "manifest.json" in zf.namelist()
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
finally:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
|
||||
assert manifest["app_version"] == APP_VERSION
|
||||
assert manifest["format"] == 1
|
||||
assert manifest["database"] in ("sqlite", "postgresql")
|
||||
assert manifest["created_at"]
|
||||
|
||||
|
||||
def _incompatible_backup(tmp_path: Path, *, version: str) -> bytes:
|
||||
"""A backup whose `cost_centers` has no `name` -- NOT NULL, no default."""
|
||||
db = tmp_path / "bambuddy.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)")
|
||||
conn.execute("INSERT INTO cost_centers (id, code) VALUES (1, 'abc')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as zf:
|
||||
zf.write(db, "bambuddy.db")
|
||||
zf.writestr("manifest.json", json.dumps({"format": 1, "app_version": version}))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_unimportable_backup_is_refused_with_both_versions(async_client, tmp_path):
|
||||
"""A 400 naming the two versions, and -- the point -- nothing touched.
|
||||
|
||||
is_sqlite is patched false because this is the PostgreSQL path: a SQLite
|
||||
install restores by copying the backup's pages, schema and all, and has
|
||||
never had this problem.
|
||||
"""
|
||||
payload = _incompatible_backup(tmp_path, version="99.9.9")
|
||||
|
||||
with patch("backend.app.core.db_dialect.is_sqlite", return_value=False):
|
||||
response = await async_client.post(
|
||||
"/api/v1/settings/restore",
|
||||
files={"file": ("backup.zip", payload, "application/zip")},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
detail = response.json()["detail"]
|
||||
assert "cost_centers.name" in detail
|
||||
assert "99.9.9" in detail
|
||||
assert APP_VERSION in detail
|
||||
assert "Nothing has been changed" in detail
|
||||
@@ -265,7 +265,7 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):
|
||||
"""get_stored_token with user=None and no global token returns (None, None)."""
|
||||
from backend.app.api.routes.cloud import get_stored_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
token, email, region = await get_stored_token(db_session, user=None)
|
||||
assert token is None
|
||||
@@ -275,7 +275,8 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_and_get_global_token(self, db_session):
|
||||
"""store_token with user=None stores in global Settings table."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
|
||||
token, email, region = await get_stored_token(db_session, user=None)
|
||||
@@ -286,9 +287,10 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_and_get_per_user_token(self, db_session):
|
||||
"""store_token with user stores on the user record."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
|
||||
db_session.add(user)
|
||||
@@ -309,9 +311,10 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_user_token_does_not_affect_global(self, db_session):
|
||||
"""Storing per-user token should not affect global Settings."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
|
||||
db_session.add(user)
|
||||
@@ -352,7 +355,8 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_global_token(self, db_session):
|
||||
"""clear_token with user=None clears from global Settings."""
|
||||
from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import clear_token, store_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
await store_token(db_session, "global-token", "global@test.com", "global", user=None)
|
||||
await clear_token(db_session, user=None)
|
||||
@@ -365,9 +369,10 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_users_independent_tokens(self, db_session):
|
||||
"""Two users should have completely independent cloud tokens and regions."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
|
||||
user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
|
||||
@@ -406,9 +411,10 @@ class TestCloudRegionPersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_region_survives_roundtrip_per_user(self, db_session):
|
||||
"""Stored China region is returned on subsequent get_stored_token calls."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
|
||||
db_session.add(user)
|
||||
@@ -429,7 +435,8 @@ class TestCloudRegionPersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_region_survives_roundtrip_global_fallback(self, db_session):
|
||||
"""Stored China region in auth-disabled Settings fallback survives too."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
await store_token(db_session, "cn-token", "token-auth", "china", user=None)
|
||||
_token, _email, region = await get_stored_token(db_session, user=None)
|
||||
@@ -438,7 +445,8 @@ class TestCloudRegionPersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_region_is_normalised_to_global(self, db_session):
|
||||
"""Unknown region values fall back to 'global' rather than mis-route."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
await store_token(db_session, "t", "x@test.com", "mars", user=None)
|
||||
_token, _email, region = await get_stored_token(db_session, user=None)
|
||||
|
||||
@@ -20,16 +20,16 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes import cloud as cloud_routes
|
||||
from backend.app.api.routes.cloud import (
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud import BambuCloudError
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
CLOUD_EMAIL_KEY,
|
||||
CLOUD_REGION_KEY,
|
||||
CLOUD_TOKEN_KEY,
|
||||
get_stored_token,
|
||||
)
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud import BambuCloudError
|
||||
|
||||
|
||||
async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""A plate printed entirely from the external spool must dispatch use_ams=False (#3087).
|
||||
|
||||
The reporter's P1S sat at "Heatbed preheating" for ten and a half minutes and
|
||||
then paused with 07FF_8012, "Failed to get AMS mapping table". The plate was one
|
||||
filament, mapped by hand to the external spool, out of a seven-filament
|
||||
MakerWorld project -- so the mapping was ``[-1, -1, -1, -1, -1, -1, 254]`` and
|
||||
the command went out as ``use_ams: true`` with a flat mapping of nothing but
|
||||
-1 (254 is deliberately not sent raw: the firmware reads it as AMS tray 0).
|
||||
|
||||
The decision belongs here rather than in the MQTT command builder. Down there a
|
||||
-1 is either padding for a filament this plate does not print -- BambuStudio's
|
||||
own convention, and what the other six entries are -- or a slot that never
|
||||
resolved, which must never be redirected to the spool holder (#2589). The two
|
||||
are the same byte. Only the plate's own filament list tells them apart, and
|
||||
``extract_filament_requirements`` already drops anything with ``used_g <= 0``,
|
||||
so it names exactly the slots that are printed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import backend.app.models # noqa: F401 - populate Base.metadata
|
||||
import backend.app.services.print_scheduler as scheduler_module
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings # noqa: F401 - registers the table
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
from backend.tests._fixtures.background_tasks import discarding_spawn_patch
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# The reporter's plate: filament 7 of a seven-filament project, and it is the
|
||||
# only one this plate consumes. slice_info.config lists a plate's filaments by
|
||||
# their project-wide id, which is why the mapping is seven long.
|
||||
_PLATE_4_ONE_FILAMENT = '<filament id="7" used_g="12.4" type="PLA" color="#F98C36"/>'
|
||||
|
||||
|
||||
def _write_3mf(path: Path, plate_index: int = 4, filaments: str = _PLATE_4_ONE_FILAMENT) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr(
|
||||
"Metadata/slice_info.config",
|
||||
f'<config><plate><metadata key="index" value="{plate_index}"/>{filaments}</plate></config>',
|
||||
)
|
||||
|
||||
|
||||
def _write_3mf_without_slice_info(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr("3D/3dmodel.model", "<model/>")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dispatch_case(tmp_path):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
base_dir = tmp_path / "external-spool"
|
||||
|
||||
async def _build(
|
||||
mapping, *, use_ams=True, plate_id=4, filaments=_PLATE_4_ONE_FILAMENT, slice_info=True, model="P1S"
|
||||
):
|
||||
archive_rel = Path("archives") / f"plate-{plate_id}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf"
|
||||
if slice_info:
|
||||
_write_3mf(base_dir / archive_rel, plate_index=plate_id, filaments=filaments)
|
||||
else:
|
||||
_write_3mf_without_slice_info(base_dir / archive_rel)
|
||||
|
||||
async with session_maker() as db:
|
||||
printer = Printer(
|
||||
name="P1S",
|
||||
serial_number=f"01P{abs(hash(str(mapping))) % 10**9}",
|
||||
ip_address="127.0.0.1",
|
||||
access_code="access-code",
|
||||
model=model,
|
||||
)
|
||||
db.add(printer)
|
||||
await db.flush()
|
||||
archive = PrintArchive(
|
||||
printer_id=printer.id,
|
||||
filename=archive_rel.name,
|
||||
file_path=str(archive_rel),
|
||||
file_size=(base_dir / archive_rel).stat().st_size,
|
||||
status="completed",
|
||||
)
|
||||
db.add(archive)
|
||||
await db.flush()
|
||||
item = PrintQueueItem(
|
||||
printer_id=printer.id,
|
||||
archive_id=archive.id,
|
||||
plate_id=plate_id,
|
||||
status="pending",
|
||||
use_ams=use_ams,
|
||||
ams_mapping=json.dumps(mapping) if mapping is not None else None,
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
return SimpleNamespace(item_id=item.id, printer_id=printer.id)
|
||||
|
||||
try:
|
||||
yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, build=_build)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _dispatch(ctx, ids, status=None):
|
||||
scheduler = PrintScheduler()
|
||||
start_print = MagicMock(return_value=True)
|
||||
status = status or SimpleNamespace(state="IDLE", nozzle_rack=None, raw_data={}, nozzles=[])
|
||||
|
||||
with ExitStack() as stack:
|
||||
for patcher in (
|
||||
patch.object(scheduler_module, "async_session", ctx.session_maker),
|
||||
patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
|
||||
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
|
||||
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
|
||||
patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
|
||||
patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
|
||||
patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
|
||||
patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
|
||||
patch(
|
||||
"backend.app.services.print_scheduler.get_ftp_retry_settings",
|
||||
AsyncMock(return_value=(False, 3, 2.0, 30.0)),
|
||||
),
|
||||
patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
|
||||
discarding_spawn_patch(),
|
||||
patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
|
||||
patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
|
||||
patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
|
||||
patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
|
||||
patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
|
||||
patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
|
||||
):
|
||||
stack.enter_context(patcher)
|
||||
await scheduler._dispatch_one(ids.item_id)
|
||||
|
||||
assert start_print.call_count == 1, "the print command was never sent"
|
||||
return start_print.call_args
|
||||
|
||||
|
||||
class TestThePlateThatOnlyPrintsFromTheSpoolHolder:
|
||||
async def test_the_reporters_mapping_dispatches_without_the_ams(self, dispatch_case):
|
||||
"""[-1]*6 + [254] on a plate whose only printed filament is #7."""
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254])
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is False
|
||||
# The mapping itself still goes out untouched — the builder is what
|
||||
# turns 254 into -1 plus ams_mapping2, and none of that changes.
|
||||
assert call.kwargs["ams_mapping"] == [-1, -1, -1, -1, -1, -1, 254]
|
||||
|
||||
async def test_the_main_nozzle_sentinel_counts_too(self, dispatch_case):
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 255])
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is False
|
||||
|
||||
async def test_an_unpadded_single_filament_plate_is_unaffected(self, dispatch_case):
|
||||
"""[254] already worked: the MQTT command builder downgrades an
|
||||
all-external mapping by itself. The scheduler now reaches the same
|
||||
answer one layer earlier, so the two agree rather than one undoing the
|
||||
other — this pins that they do."""
|
||||
ids = await dispatch_case.build([254], filaments='<filament id="1" used_g="9.0" type="PLA"/>', plate_id=1)
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is False
|
||||
|
||||
|
||||
class TestWhatMustNotChange:
|
||||
async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case):
|
||||
"""The #2589 contract, and the reason this lives in the scheduler.
|
||||
|
||||
Filaments 1 and 7 are both printed; 7 is on the spool holder and 1
|
||||
resolved to nothing. Redirecting the plate to the external spool would
|
||||
print filament 1 in the wrong material without saying so. use_ams stays
|
||||
true and the firmware rejects the print, exactly as before.
|
||||
"""
|
||||
ids = await dispatch_case.build(
|
||||
[-1, -1, -1, -1, -1, -1, 254],
|
||||
filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
|
||||
)
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_a_plate_mixing_an_ams_tray_with_the_spool_holder_keeps_the_ams(self, dispatch_case):
|
||||
ids = await dispatch_case.build(
|
||||
[5, -1, -1, -1, -1, -1, 254],
|
||||
filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
|
||||
)
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_a_plate_printed_from_ams_trays_is_untouched(self, dispatch_case):
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5])
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_use_ams_false_is_never_promoted_here(self, dispatch_case):
|
||||
"""Promotion is the builder's job (#2595) and stays there."""
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5], use_ams=False)
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is False
|
||||
|
||||
async def test_a_3mf_with_no_filament_list_falls_back_to_the_stored_flag(self, dispatch_case):
|
||||
"""No evidence, no decision — the same convention as #2771."""
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], slice_info=False)
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_a_plate_the_file_does_not_describe_falls_back(self, dispatch_case):
|
||||
"""The item says plate 4; the file only describes plate 1."""
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], plate_id=4)
|
||||
# Rewrite the archive's 3MF so its only plate is index 1.
|
||||
async with dispatch_case.session_maker() as db:
|
||||
archive = (await db.get(PrintQueueItem, ids.item_id)).archive_id
|
||||
path = dispatch_case.base_dir / (await db.get(PrintArchive, archive)).file_path
|
||||
_write_3mf(path, plate_index=1)
|
||||
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_an_item_with_no_mapping_at_all_is_untouched(self, dispatch_case):
|
||||
ids = await dispatch_case.build(None)
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
|
||||
class TestDualNozzleIsNotOursToRewrite:
|
||||
"""On a two-extruder printer use_ams is which nozzle to feed, not whether to
|
||||
use the AMS — H2D Pro firmware reads it as an extruder index. The MQTT
|
||||
command builder skips its own reconcile for exactly that reason, and this
|
||||
must skip it too, or a perfectly normal dual external-spool print gets its
|
||||
routing rewritten."""
|
||||
|
||||
async def test_a_dual_nozzle_model_keeps_its_flag(self, dispatch_case):
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2D")
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_both_external_feeds_on_a_dual_nozzle_are_left_alone(self, dispatch_case):
|
||||
"""254 is the deputy feed and 255 the main one — an ordinary H2D print
|
||||
with a spool on each side, and the one this would have broken."""
|
||||
ids = await dispatch_case.build(
|
||||
[254, -1, -1, -1, -1, -1, 255],
|
||||
filaments='<filament id="1" used_g="8.0" type="PLA"/>' + _PLATE_4_ONE_FILAMENT,
|
||||
model="H2D",
|
||||
)
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_live_telemetry_can_veto_a_single_nozzle_model_name(self, dispatch_case):
|
||||
"""A model string we do not recognise as dual is not the last word: two
|
||||
external feeds is something only a two-extruder printer reports."""
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
|
||||
status = SimpleNamespace(
|
||||
state="IDLE",
|
||||
nozzle_rack=None,
|
||||
nozzles=[],
|
||||
raw_data={"vt_tray": [{"id": "254"}, {"id": "255"}]},
|
||||
)
|
||||
call = await _dispatch(dispatch_case, ids, status=status)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_a_second_nozzle_reporting_a_diameter_vetoes_it_too(self, dispatch_case):
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
|
||||
status = SimpleNamespace(
|
||||
state="IDLE",
|
||||
nozzle_rack=None,
|
||||
nozzles=[SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")],
|
||||
raw_data={},
|
||||
)
|
||||
call = await _dispatch(dispatch_case, ids, status=status)
|
||||
|
||||
assert call.kwargs["use_ams"] is True
|
||||
|
||||
async def test_h2s_is_single_nozzle_and_still_gets_the_fix(self, dispatch_case):
|
||||
"""H2S shares the H2 serial prefix and firmware quirks but has one
|
||||
extruder — the #1386 distinction, which must survive here."""
|
||||
ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2S")
|
||||
call = await _dispatch(dispatch_case, ids)
|
||||
|
||||
assert call.kwargs["use_ams"] is False
|
||||
@@ -1191,3 +1191,80 @@ class TestFinanceUserDefaults:
|
||||
|
||||
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
|
||||
assert wallet is not None
|
||||
|
||||
|
||||
class TestFinanceCurrency(TestFinanceAPI):
|
||||
"""#3123: every balance is reported in the install's configured currency.
|
||||
|
||||
Wallets used to carry a currency of their own, which three of its four
|
||||
writers filled with a hardcoded "EUR" and the Finance page rendered as it
|
||||
found it -- so an install set to AUD showed a euro balance. The column is
|
||||
gone; these tests pin what replaced it.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def aud_install(self, db_session):
|
||||
existing = await db_session.scalar(select(Settings).where(Settings.key == "currency"))
|
||||
if existing is None:
|
||||
db_session.add(Settings(key="currency", value="AUD"))
|
||||
else:
|
||||
existing.value = "AUD"
|
||||
await db_session.commit()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_balance_reports_the_configured_currency_without_a_wallet(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
db_session,
|
||||
admin_user,
|
||||
aud_install,
|
||||
):
|
||||
"""The read-only path used to answer a flat "EUR" when no wallet row existed."""
|
||||
assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == admin_user.id)) is None
|
||||
|
||||
response = await async_client.get("/api/v1/finance/me/balance", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["currency"] == "AUD"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_existing_wallet_is_reported_in_the_configured_currency(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
db_session,
|
||||
admin_user,
|
||||
aud_install,
|
||||
):
|
||||
"""The reporter's case: a wallet created back when the install said EUR."""
|
||||
db_session.add(UserWallet(user_id=admin_user.id, balance=12.34))
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get("/api/v1/finance/me/balance", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["balance"] == 12.34
|
||||
assert response.json()["currency"] == "AUD"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_adjustment_answers_in_the_configured_currency(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
db_session,
|
||||
admin_user,
|
||||
aud_install,
|
||||
):
|
||||
"""_get_or_create_wallet is the path that used to write EUR into the database."""
|
||||
response = await async_client.post(
|
||||
f"/api/v1/finance/users/{admin_user.id}/deposit",
|
||||
json={"amount": 5.0, "description": "currency check"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["balance"]["currency"] == "AUD"
|
||||
|
||||
@@ -1226,6 +1226,221 @@ class TestAssignSpoolEmptyDetection:
|
||||
assert body["configured"] is True
|
||||
|
||||
|
||||
class TestAssignSpoolPresenceBit:
|
||||
"""#3084: the slot the firmware says is full, and the cache says is empty.
|
||||
|
||||
``apply_tray_exist_bits`` stamps ``state = 9`` on every slot whose
|
||||
``tray_exist_bits`` bit is 0 and annotates ``exists`` on every slot it
|
||||
looks at — but when the bit comes back it only refreshes ``exists`` and
|
||||
leaves the 9 where it was. Swap a Bambu spool for a non-RFID one and the
|
||||
slot sits at ``exists=True, state=9`` until something configures it.
|
||||
|
||||
Reported on an H2D/H2C AMS-HT: remove the Bambu spool (bits ``f``), insert
|
||||
a third-party one 9 seconds later (bits ``1000f``), then Assign Spool 28
|
||||
seconds after that — and no ``ams_filament_setting`` was published at all.
|
||||
Configure worked, because it publishes unconditionally.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_bit_overrules_a_stale_empty_state(
|
||||
self, async_client: AsyncClient, printer_factory, spool_factory
|
||||
):
|
||||
"""exists=True with a leftover state=9 — MQTT must fire."""
|
||||
printer = await printer_factory(name="H2D")
|
||||
spool = await spool_factory(slicer_filament="GFL05", material="PLA")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.ams_set_filament_setting.return_value = True
|
||||
mock_client.extrusion_cali_sel.return_value = True
|
||||
|
||||
tray_data = {"id": 0, "state": 9, "exists": True, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
|
||||
status = _make_mock_status(ams_data=[{"id": 128, "tray": [tray_data]}])
|
||||
|
||||
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
mock_pm.get_status.return_value = status
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/inventory/assignments",
|
||||
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 128, "tray_id": 0},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_client.ams_set_filament_setting.assert_called_once()
|
||||
body = response.json()
|
||||
assert body["configured"] is True
|
||||
assert body["pending_config"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_empty_bit_does_not_start_suppressing_pushes(
|
||||
self, async_client: AsyncClient, printer_factory, spool_factory
|
||||
):
|
||||
"""The bit overrules the 9 and nothing else.
|
||||
|
||||
Reading it the other way too would be tidier — skip the push firmware
|
||||
is going to drop — but it also means a slot that silently stops
|
||||
configuring on whichever AMS variant we compute the bit position
|
||||
wrong for. The saving is one MQTT message; the failure is the bug
|
||||
this commit is fixing, inverted. So a state that does not say "empty"
|
||||
still publishes, exactly as it did before.
|
||||
"""
|
||||
printer = await printer_factory(name="H2D")
|
||||
spool = await spool_factory(slicer_filament="GFL05", material="PLA")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.ams_set_filament_setting.return_value = True
|
||||
mock_client.extrusion_cali_sel.return_value = True
|
||||
|
||||
tray_data = {"id": 3, "state": 11, "exists": False, "tray_type": "", "tray_color": ""}
|
||||
status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
|
||||
|
||||
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
mock_pm.get_status.return_value = status
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/inventory/assignments",
|
||||
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_client.ams_set_filament_setting.assert_called_once()
|
||||
assert response.json()["pending_config"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_pre_assign_workflow_still_skips_a_genuinely_empty_slot(
|
||||
self, async_client: AsyncClient, printer_factory, spool_factory
|
||||
):
|
||||
"""SpoolBuddy weighs a spool and assigns it before it goes in. Bit
|
||||
clear and state 9 agree that the slot is empty, so the push is still
|
||||
deferred to the replay — unchanged."""
|
||||
printer = await printer_factory(name="H2D")
|
||||
spool = await spool_factory(slicer_filament="GFL05", material="PLA")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.ams_set_filament_setting.return_value = True
|
||||
|
||||
tray_data = {"id": 3, "state": 9, "exists": False, "tray_type": "", "tray_color": ""}
|
||||
status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
|
||||
|
||||
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
mock_pm.get_status.return_value = status
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/inventory/assignments",
|
||||
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_client.ams_set_filament_setting.assert_not_called()
|
||||
body = response.json()
|
||||
assert body["configured"] is False
|
||||
assert body["pending_config"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_unannotated_tray_still_reads_the_state(
|
||||
self, async_client: AsyncClient, printer_factory, spool_factory
|
||||
):
|
||||
"""No presence bit in the payload → the 9/10 heuristic still decides.
|
||||
|
||||
The external spool's ``vt_tray`` has no bit in the mask, and neither do
|
||||
the hand-built payloads every other test in this file uses.
|
||||
"""
|
||||
printer = await printer_factory(name="H2D")
|
||||
spool = await spool_factory(slicer_filament="GFL05", material="PLA")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.ams_set_filament_setting.return_value = True
|
||||
|
||||
status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "state": 9, "tray_type": ""}]}])
|
||||
|
||||
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
mock_pm.get_status.return_value = status
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/inventory/assignments",
|
||||
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_client.ams_set_filament_setting.assert_not_called()
|
||||
assert response.json()["pending_config"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deferred_config_fires_for_a_spool_the_ams_cannot_name(
|
||||
self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
||||
):
|
||||
"""The pre-assign workflow's half of the same bug.
|
||||
|
||||
A non-RFID spool inserted into a pre-assigned slot brings no
|
||||
``tray_type`` with it, and the stale 9 kept the replay's "loaded" test
|
||||
false, so the deferred configuration never fired for it either. The
|
||||
presence bit is the only thing in the payload that changed.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from backend.app.main import on_ams_change
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
|
||||
printer = await printer_factory(name="H2D")
|
||||
spool = await spool_factory(slicer_filament="GFL05", material="PLA")
|
||||
|
||||
pre_assignment = SpoolAssignment(
|
||||
spool_id=spool.id,
|
||||
printer_id=printer.id,
|
||||
ams_id=2,
|
||||
tray_id=3,
|
||||
fingerprint_color=None,
|
||||
fingerprint_type=None,
|
||||
)
|
||||
db_session.add(pre_assignment)
|
||||
await db_session.commit()
|
||||
|
||||
ams_data = [{"id": 2, "tray": [{"id": 3, "state": 9, "exists": True, "tray_type": "", "tray_color": ""}]}]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.ams_set_filament_setting.return_value = True
|
||||
mock_client.extrusion_cali_sel.return_value = True
|
||||
|
||||
status = _make_mock_status(ams_data=ams_data)
|
||||
|
||||
with (
|
||||
patch("backend.app.main.printer_manager") as mock_pm_main,
|
||||
patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
|
||||
patch("backend.app.main.mqtt_relay") as mock_relay,
|
||||
patch("backend.app.main.ws_manager") as mock_ws,
|
||||
):
|
||||
mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
|
||||
mock_pm_main.get_status.return_value = status
|
||||
mock_pm_main.get_client.return_value = mock_client
|
||||
mock_pm_main.get_model.return_value = "H2D"
|
||||
mock_pm_inv.get_client.return_value = mock_client
|
||||
mock_pm_inv.get_status.return_value = status
|
||||
mock_relay.on_ams_change = AsyncMock()
|
||||
mock_ws.send_printer_status = AsyncMock()
|
||||
mock_ws.broadcast = AsyncMock()
|
||||
|
||||
await on_ams_change(printer.id, ams_data)
|
||||
|
||||
mock_client.ams_set_filament_setting.assert_called_once()
|
||||
call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
|
||||
assert call_kwargs["ams_id"] == 2
|
||||
assert call_kwargs["tray_id"] == 3
|
||||
assert call_kwargs["tray_info_idx"] == "GFL05"
|
||||
|
||||
# The assignment is still there — the pass that fires the config is the
|
||||
# same pass that deletes stale ones (#3100).
|
||||
db_session.expunge_all()
|
||||
assert await db_session.get(SpoolAssignment, pre_assignment.id) is not None
|
||||
|
||||
|
||||
class TestAssignSpoolPfcnCloudPreset:
|
||||
"""Assign path for PFCN-prefix cloud presets (#1648).
|
||||
|
||||
@@ -1561,6 +1776,118 @@ class TestAutoUnlinkDuringRunout:
|
||||
assert await db_session.get(SpoolAssignment, assignment_id) is not None
|
||||
|
||||
|
||||
class TestAutoUnlinkOccupiedSlot:
|
||||
"""#3100: six saved assignments deleted across three X1 Carbons.
|
||||
|
||||
Each one had an explicit earlier assignment and then an ``Auto-unlink ...
|
||||
fingerprint mismatch``, and the reporter recovered the mappings from logs
|
||||
because they were gone from the inventory API, not merely hidden. Two
|
||||
shapes, one cause: a slot the presence bit calls occupied while the tray
|
||||
reports nothing about what is in it.
|
||||
|
||||
``cur=/ fp=BCBCBCFF/PLA spool=8A8F92FF/PLA`` — an established assignment,
|
||||
a blank idle report, and the row deleted. The spool never went anywhere;
|
||||
the AMS simply had nothing to say about a filament it cannot read.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def _run(printer_id, ams_data, status):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from backend.app.main import on_ams_change
|
||||
|
||||
with (
|
||||
patch("backend.app.main.printer_manager") as mock_pm_main,
|
||||
patch("backend.app.main.mqtt_relay") as mock_relay,
|
||||
patch("backend.app.main.ws_manager") as mock_ws,
|
||||
):
|
||||
mock_pm_main.get_printer.return_value = MagicMock(name="X1C", serial_number="0948BB540200427")
|
||||
mock_pm_main.get_status.return_value = status
|
||||
mock_pm_main.get_model.return_value = "X1C"
|
||||
mock_pm_main.get_client.return_value = None
|
||||
mock_relay.on_ams_change = AsyncMock()
|
||||
mock_ws.send_printer_status = AsyncMock()
|
||||
mock_ws.broadcast = AsyncMock()
|
||||
|
||||
await on_ams_change(printer_id, ams_data)
|
||||
|
||||
@staticmethod
|
||||
async def _assignment(db_session, printer, spool):
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
|
||||
assignment = SpoolAssignment(
|
||||
spool_id=spool.id,
|
||||
printer_id=printer.id,
|
||||
ams_id=1,
|
||||
tray_id=1,
|
||||
fingerprint_color="BCBCBCFF",
|
||||
fingerprint_type="PLA",
|
||||
)
|
||||
db_session.add(assignment)
|
||||
await db_session.commit()
|
||||
return assignment.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_blank_report_from_an_occupied_slot_keeps_the_assignment(
|
||||
self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
||||
):
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
|
||||
printer = await printer_factory(name="X1C")
|
||||
spool = await spool_factory(material="PLA", rgba="8A8F92FF")
|
||||
assignment_id = await self._assignment(db_session, printer, spool)
|
||||
|
||||
ams_data = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
|
||||
await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
|
||||
|
||||
db_session.expunge_all()
|
||||
assert await db_session.get(SpoolAssignment, assignment_id) is not None, (
|
||||
"a spool the AMS cannot identify is not a spool that was removed"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_blank_report_from_an_empty_slot_still_unlinks(
|
||||
self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
||||
):
|
||||
"""The guard reads the bit, not the blankness — take the spool out and
|
||||
the assignment still goes, which is what makes the test above a
|
||||
distinction rather than a blanket reprieve."""
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
|
||||
printer = await printer_factory(name="X1C")
|
||||
spool = await spool_factory(material="PLA", rgba="8A8F92FF")
|
||||
assignment_id = await self._assignment(db_session, printer, spool)
|
||||
|
||||
ams_data = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
|
||||
await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
|
||||
|
||||
db_session.expunge_all()
|
||||
assert await db_session.get(SpoolAssignment, assignment_id) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_genuinely_different_filament_in_an_occupied_slot_still_unlinks(
|
||||
self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
||||
):
|
||||
"""The guard is for a blank report only. A slot that names a filament
|
||||
which is not the assigned spool is a swap, bit set or not."""
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
|
||||
printer = await printer_factory(name="X1C")
|
||||
spool = await spool_factory(material="PLA", rgba="8A8F92FF")
|
||||
assignment_id = await self._assignment(db_session, printer, spool)
|
||||
|
||||
ams_data = [
|
||||
{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "PETG", "tray_color": "00FF00FF", "state": 11}]}
|
||||
]
|
||||
await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
|
||||
|
||||
db_session.expunge_all()
|
||||
assert await db_session.get(SpoolAssignment, assignment_id) is None
|
||||
|
||||
|
||||
class TestSpoolmanSlotAssignmentDuringRunout:
|
||||
"""`spoolman_slot_assignments` is how a tag-less spool assigned through the
|
||||
Bambuddy UI is resolved at completion (#1459). Deleting the row when a slot
|
||||
@@ -1578,14 +1905,14 @@ class TestSpoolmanSlotAssignmentDuringRunout:
|
||||
db_session.add(Settings(key=key, value=value))
|
||||
await db_session.commit()
|
||||
|
||||
async def _run(self, printer_id, state):
|
||||
async def _run(self, printer_id, state, tray=None):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from backend.app.main import on_ams_change
|
||||
|
||||
# A tray the firmware has cleared: parse_ams_tray returns None, which
|
||||
# is what marks the slot empty for the cleanup pass.
|
||||
ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
|
||||
ams_data = [{"id": 0, "tray": [tray or {"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
|
||||
|
||||
spoolman_client = MagicMock()
|
||||
spoolman_client.health_check = AsyncMock(return_value=True)
|
||||
@@ -1628,6 +1955,37 @@ class TestSpoolmanSlotAssignmentDuringRunout:
|
||||
db_session.expunge_all()
|
||||
assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_occupied_slot_the_ams_cannot_read_keeps_its_row(
|
||||
self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
|
||||
):
|
||||
"""Spoolman mode's half of #3100.
|
||||
|
||||
parse_ams_tray calls a tray with no type empty, and a tag-less spool
|
||||
has none until something configures it — so the row assigned through
|
||||
the UI was deleted by the first idle push after the spool went in.
|
||||
Firmware's presence bit is the same answer the built-in inventory
|
||||
uses, so the two modes stay in step.
|
||||
"""
|
||||
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
||||
|
||||
await self._enable_spoolman(db_session)
|
||||
printer = await printer_factory(name="H2D")
|
||||
row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
row_id = row.id
|
||||
|
||||
await self._run(
|
||||
printer.id,
|
||||
_make_printing_status(None, state="IDLE"),
|
||||
tray={"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9},
|
||||
)
|
||||
|
||||
db_session.expunge_all()
|
||||
assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_slot_row_is_still_cleaned_up_when_idle(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Conflict handling on PATCH /api/v1/inventory/spools/{id}/link-tag (#3110).
|
||||
|
||||
The route loaded the conflicting spool row and then threw it away, refusing
|
||||
with a bare "already linked to another active spool" -- so a client could not
|
||||
tell which spool to look at, and could not offer to move the tag. It also read
|
||||
that row with ``scalar_one_or_none()``, which raises ``MultipleResultsFound``
|
||||
when two active spools carry one tag. Nothing prevents that duplicate: no
|
||||
unique index, no conflict check on PATCH /spools/{id}, and POST /spools/bulk
|
||||
copies one tag into every row it creates.
|
||||
|
||||
That exception escapes the route into the auth middleware's fail-closed
|
||||
``except Exception`` (main.py:9685), so the caller does not even get a 500 --
|
||||
they get 503 "Authentication service temporarily unavailable" for a request
|
||||
that has nothing to do with auth. The middleware is right to fail closed
|
||||
(GHSA-6mf4-q26m-47pv); the route is what must not raise.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.spool import Spool
|
||||
|
||||
TAG = "AABBCCDD"
|
||||
TRAY_UUID = "AABBCCDDEEFF0011AABBCCDDEEFF0011"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def spool_factory(db_session: AsyncSession):
|
||||
async def _create_spool(**kwargs):
|
||||
defaults = {
|
||||
"material": "PLA",
|
||||
"subtype": "Basic",
|
||||
"brand": "Devil Design",
|
||||
"rgba": "FF0000FF",
|
||||
"label_weight": 1000,
|
||||
"weight_used": 0,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
spool = Spool(**defaults)
|
||||
db_session.add(spool)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(spool)
|
||||
return spool
|
||||
|
||||
return _create_spool
|
||||
|
||||
|
||||
class TestLinkTagNamesTheHolder:
|
||||
"""The 409 carries the id the route already had in hand."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_tag_uid_conflict_names_the_spool_holding_it(self, async_client: AsyncClient, spool_factory):
|
||||
holder = await spool_factory(tag_uid=TAG)
|
||||
target = await spool_factory()
|
||||
|
||||
resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
|
||||
|
||||
assert resp.status_code == 409
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["code"] == "tag_already_linked"
|
||||
assert detail["spool_id"] == holder.id
|
||||
assert detail["field"] == "tag_uid"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_tray_uuid_conflict_names_the_spool_holding_it(self, async_client: AsyncClient, spool_factory):
|
||||
holder = await spool_factory(tray_uuid=TRAY_UUID)
|
||||
target = await spool_factory()
|
||||
|
||||
resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tray_uuid": TRAY_UUID})
|
||||
|
||||
assert resp.status_code == 409
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["spool_id"] == holder.id
|
||||
# Which identifier collided, so a client knows what it would be moving.
|
||||
assert detail["field"] == "tray_uuid"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_free_tag_still_links(self, async_client: AsyncClient, spool_factory):
|
||||
"""Regression guard: the conflict rewrite must not refuse a clean link."""
|
||||
target = await spool_factory()
|
||||
|
||||
resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tag_uid"] == TAG
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_archived_holder_still_yields_the_tag(self, async_client: AsyncClient, spool_factory):
|
||||
"""Regression guard: tag recycling off archived spools is untouched."""
|
||||
archived = await spool_factory(tag_uid=TAG, archived_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
target = await spool_factory()
|
||||
|
||||
resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
|
||||
|
||||
assert resp.status_code == 200
|
||||
reread = await async_client.get(f"/api/v1/inventory/spools/{archived.id}")
|
||||
assert reread.json()["tag_uid"] is None
|
||||
|
||||
|
||||
class TestLinkTagDuplicateHolders:
|
||||
"""Two active spools on one tag is a 409 naming the lowest id, not a crash."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_duplicate_tag_uid_holders_yield_a_409_not_a_crash(self, async_client: AsyncClient, spool_factory):
|
||||
first = await spool_factory(tag_uid=TAG)
|
||||
await spool_factory(tag_uid=TAG)
|
||||
target = await spool_factory()
|
||||
|
||||
resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["spool_id"] == first.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_duplicate_tray_uuid_holders_yield_a_409_not_a_crash(self, async_client: AsyncClient, spool_factory):
|
||||
first = await spool_factory(tray_uuid=TRAY_UUID)
|
||||
await spool_factory(tray_uuid=TRAY_UUID)
|
||||
target = await spool_factory()
|
||||
|
||||
resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tray_uuid": TRAY_UUID})
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["spool_id"] == first.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_create_is_one_route_to_that_duplicate(self, async_client: AsyncClient, spool_factory):
|
||||
"""POST /spools/bulk copies a single payload -- tag included -- N times.
|
||||
|
||||
Reached through the API rather than the fixture, so the duplicate is
|
||||
shown to be a state the app itself produces, not one only a test can
|
||||
stage.
|
||||
"""
|
||||
created = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk",
|
||||
json={"quantity": 2, "spool": {"material": "PLA", "label_weight": 1000, "tag_uid": TAG}},
|
||||
)
|
||||
assert created.status_code in (200, 201)
|
||||
ids = sorted(s["id"] for s in created.json())
|
||||
assert len(ids) == 2
|
||||
|
||||
target = await spool_factory()
|
||||
resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["spool_id"] == ids[0]
|
||||
@@ -7,6 +7,16 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
|
||||
async def _read_queue_item(db_session, item_id: int) -> PrintQueueItem:
|
||||
"""Re-read a queue row the route just committed through its own session."""
|
||||
db_session.expire_all()
|
||||
return (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
|
||||
|
||||
|
||||
class TestLibraryFoldersAPI:
|
||||
@@ -679,19 +689,43 @@ class TestLibraryAddToQueueAPI:
|
||||
|
||||
return _create_library_file
|
||||
|
||||
@pytest.fixture
|
||||
async def on_disk_file_factory(self, library_file_factory):
|
||||
"""A library file whose bytes exist, so the route gets past its disk check."""
|
||||
written: list[Path] = []
|
||||
|
||||
async def _create(**kwargs):
|
||||
counter = len(written) + 1
|
||||
rel_path = kwargs.pop("file_path", f"archive/library/files/queue_probe_{counter}.gcode.3mf")
|
||||
abs_path = Path(app_settings.base_dir) / rel_path
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
abs_path.write_bytes(b"probe")
|
||||
written.append(abs_path)
|
||||
kwargs.setdefault("filename", f"queue_probe_{counter}.gcode.3mf")
|
||||
return await library_file_factory(file_path=rel_path, **kwargs)
|
||||
|
||||
yield _create
|
||||
|
||||
for path in written:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
|
||||
"""Verify error for non-existent file."""
|
||||
"""Nothing queued is not a success (#3112).
|
||||
|
||||
This used to assert 200: the caller got an OK for a call that created
|
||||
nothing, with the reason in a body it had no cause to read. The reason
|
||||
is still reported, now where a failed call puts it.
|
||||
"""
|
||||
await printer_factory()
|
||||
|
||||
data = {"file_ids": [9999]}
|
||||
response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert len(result["added"]) == 0
|
||||
assert len(result["errors"]) == 1
|
||||
assert result["errors"][0]["file_id"] == 9999
|
||||
assert response.status_code == 400
|
||||
errors = response.json()["detail"]["errors"]
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["file_id"] == 9999
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -708,11 +742,172 @@ class TestLibraryAddToQueueAPI:
|
||||
|
||||
data = {"file_ids": [lib_file.id]}
|
||||
response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
|
||||
assert response.status_code == 400
|
||||
errors = response.json()["detail"]["errors"]
|
||||
assert len(errors) == 1
|
||||
assert "sliced" in errors[0]["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_partial_success_still_returns_200(
|
||||
self, async_client: AsyncClient, printer_factory, library_file_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
"""Items really were created, so the call succeeded (#3112).
|
||||
|
||||
The per-file errors ride along with them, which is the whole point of a
|
||||
bulk endpoint. Only a call that produced nothing is a failed call.
|
||||
"""
|
||||
await printer_factory()
|
||||
good = await on_disk_file_factory()
|
||||
bad = await library_file_factory(filename="model.stl", file_path="/test/path/model.stl", file_type="stl")
|
||||
|
||||
response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [good.id, bad.id]})
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert len(result["added"]) == 0
|
||||
assert len(result["errors"]) == 1
|
||||
assert "sliced" in result["errors"][0]["error"].lower()
|
||||
assert [a["file_id"] for a in result["added"]] == [good.id]
|
||||
assert [e["file_id"] for e in result["errors"]] == [bad.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_target_model_is_inferred_so_the_item_can_be_dispatched(
|
||||
self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
"""#3112: an item with no printer and no target model is inert.
|
||||
|
||||
The scheduler dispatches on `item.printer_id` or on
|
||||
`item.target_model or item.variants`; a row with neither matches no
|
||||
branch and waits forever. With an active X1C present, a file that says
|
||||
it was sliced for one is aimed at it.
|
||||
"""
|
||||
await printer_factory(model="X1C")
|
||||
lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
|
||||
|
||||
response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
|
||||
assert response.status_code == 200
|
||||
item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
|
||||
assert item.printer_id is None
|
||||
assert item.target_model == "X1C"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_no_printer_of_that_model_leaves_the_item_unassigned(
|
||||
self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
"""Aiming an item at hardware nobody owns would only look like progress.
|
||||
|
||||
Having no H2D is the user's situation, not their mistake, so the file
|
||||
is still queued -- as the unassigned row it has always been.
|
||||
"""
|
||||
await printer_factory(model="X1C")
|
||||
lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
|
||||
|
||||
response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
|
||||
assert response.status_code == 200
|
||||
item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
|
||||
assert item.printer_id is None
|
||||
assert item.target_model is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_explicit_printer_wins_over_the_files_own_model(
|
||||
self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
printer = await printer_factory(model="X1C")
|
||||
# Read before the queue row is re-read: that expires the session, and
|
||||
# a lazy refresh of this row would then happen outside the greenlet.
|
||||
printer_id = printer.id
|
||||
lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
json={"file_ids": [lib_file.id], "printer_id": printer_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
|
||||
assert item.printer_id == printer_id
|
||||
assert item.target_model is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_incompatible_target_model_is_refused_per_file(
|
||||
self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
"""The same cross-model gate POST /queue/ applies (#2578).
|
||||
|
||||
The scheduler hands model-based items to hardware with no human in the
|
||||
loop, so a file sliced for one model must not be aimed at another.
|
||||
"""
|
||||
await printer_factory(model="A1")
|
||||
lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
json={"file_ids": [lib_file.id], "target_model": "A1"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "cannot be dispatched" in response.json()["detail"]["errors"][0]["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_target_model_without_an_active_printer_is_refused(
|
||||
self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
await printer_factory(model="X1C")
|
||||
lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
json={"file_ids": [lib_file.id], "target_model": "H2D"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "No active printers" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_printer_and_target_model_together_are_refused(
|
||||
self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
printer = await printer_factory(model="X1C")
|
||||
lib_file = await on_disk_file_factory()
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
json={"file_ids": [lib_file.id], "printer_id": printer.id, "target_model": "X1C"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "both" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unknown_printer_is_refused(
|
||||
self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
await printer_factory(model="X1C")
|
||||
lib_file = await on_disk_file_factory()
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
json={"file_ids": [lib_file.id], "printer_id": 999999},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Printer not found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_filename_the_printer_cannot_store_is_refused(
|
||||
self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
|
||||
):
|
||||
"""The Bambu SD card is FAT32; an illegal character 553s at upload.
|
||||
|
||||
POST /queue/ has rejected these at queue time since #1540. This route
|
||||
did not, so the mistake surfaced as a print that failed later.
|
||||
"""
|
||||
await printer_factory(model="X1C")
|
||||
lib_file = await on_disk_file_factory(filename="bad:name?.gcode.3mf")
|
||||
|
||||
response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["errors"][0]["file_id"] == lib_file.id
|
||||
|
||||
|
||||
class TestLibraryZipExtractAPI:
|
||||
|
||||
@@ -29,6 +29,12 @@ from backend.app.core.auth import generate_api_key
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.model_providers.base import (
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
)
|
||||
|
||||
|
||||
async def _setup_auth_with_admin(client: AsyncClient) -> str:
|
||||
@@ -104,6 +110,20 @@ def _fake_service(**stubs):
|
||||
return svc
|
||||
|
||||
|
||||
def _download_info(
|
||||
model_id: int = 1400373,
|
||||
profile_id: int = 298919107,
|
||||
name: str = "cube.3mf",
|
||||
) -> ProviderDownloadInfo:
|
||||
"""What ``service.get_download`` hands the route — signed URL + raw
|
||||
upstream name + enriched ref (sub_id carries the resolved profile)."""
|
||||
return ProviderDownloadInfo(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
|
||||
url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
|
||||
suggested_filename=name,
|
||||
)
|
||||
|
||||
|
||||
class TestStatusEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -161,9 +181,13 @@ class TestResolveEndpoint:
|
||||
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
|
||||
key = await _make_key(db_session, owner=admin, name="resolve-cloud")
|
||||
|
||||
design = {"id": 1400373, "modelId": "US2bb73b106683e5", "title": "Cube", "instances": []}
|
||||
instances = {"total": 0, "hits": []}
|
||||
svc = _fake_service(get_design=design, get_design_instances=instances)
|
||||
svc = _fake_service(
|
||||
resolve=ProviderResolvedModel(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
|
||||
design={"id": 1400373, "title": "Cube"},
|
||||
instances=[],
|
||||
)
|
||||
)
|
||||
build = AsyncMock(return_value=svc)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", build):
|
||||
@@ -173,14 +197,22 @@ class TestResolveEndpoint:
|
||||
headers={"X-API-Key": key},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# _build_service receives (db, user); the user arg must be the owning admin.
|
||||
# Without the fix it'd be None (the API-key dep value).
|
||||
# _build_service receives (db, provider, current_user, api_key_cloud_owner).
|
||||
# Identity resolution lives in the provider now: for an API-keyed
|
||||
# call current_user is None by design and the key's owner must arrive
|
||||
# via api_key_cloud_owner — without the fix it'd be dropped entirely.
|
||||
assert build.await_count == 1
|
||||
passed_user = (
|
||||
build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
|
||||
jwt_user = (
|
||||
build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
|
||||
)
|
||||
assert passed_user is not None, "resolve_url must pass the API-key owner, not None"
|
||||
assert passed_user.id == admin.id
|
||||
key_owner = (
|
||||
build.await_args.args[3]
|
||||
if len(build.await_args.args) > 3
|
||||
else build.await_args.kwargs.get("api_key_cloud_owner")
|
||||
)
|
||||
assert jwt_user is None, "API-keyed callers present no JWT user"
|
||||
assert key_owner is not None, "resolve_url must forward the API-key owner to the provider"
|
||||
assert key_owner.id == admin.id
|
||||
|
||||
|
||||
class TestImportEndpoint:
|
||||
@@ -195,23 +227,12 @@ class TestImportEndpoint:
|
||||
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
|
||||
key = await _make_key(db_session, owner=admin, name="import-cloud")
|
||||
|
||||
design = {
|
||||
"id": 1400373,
|
||||
"modelId": "US2bb73b106683e5",
|
||||
"title": "Cube",
|
||||
"instances": [{"profileId": 298919107, "title": "default"}],
|
||||
}
|
||||
manifest = {
|
||||
"name": "cube.3mf",
|
||||
"url": "https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
|
||||
}
|
||||
# 3MF download returns (bytes, filename). The bytes don't have to be a
|
||||
# valid zip — save_3mf_bytes_to_library stores them as-is and the
|
||||
# downstream thumbnail extractor swallows errors.
|
||||
# The 3MF bytes don't have to be a valid zip —
|
||||
# save_3mf_bytes_to_library stores them as-is and the downstream
|
||||
# thumbnail extractor swallows errors.
|
||||
svc = _fake_service(
|
||||
get_design=design,
|
||||
get_profile_download=manifest,
|
||||
download_3mf=(b"PK\x03\x04fake-3mf-bytes", "cube.3mf"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -249,16 +270,9 @@ class TestImportEndpoint:
|
||||
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
|
||||
key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
|
||||
|
||||
design = {
|
||||
"id": 1400373,
|
||||
"modelId": "US2bb73b106683e5",
|
||||
"instances": [{"profileId": 298919107}],
|
||||
}
|
||||
manifest = {"name": "cube.3mf", "url": "https://makerworld.bblmw.com/x.3mf"}
|
||||
svc = _fake_service(
|
||||
get_design=design,
|
||||
get_profile_download=manifest,
|
||||
download_3mf=(b"PK\x03\x04fake", "cube.3mf"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=b"PK\x03\x04fake", filename="cube.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
|
||||
@@ -270,11 +284,19 @@ class TestImportEndpoint:
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
# _build_service got None — same as before the PR for non-cloud keys.
|
||||
passed_user = (
|
||||
build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
|
||||
# Both identity slots are None — same as before the PR for non-cloud
|
||||
# keys: no JWT user, and the cloud-scope fence keeps the key's owner
|
||||
# back, so the provider builds an anonymous service.
|
||||
jwt_user = (
|
||||
build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
|
||||
)
|
||||
assert passed_user is None
|
||||
key_owner = (
|
||||
build.await_args.args[3]
|
||||
if len(build.await_args.args) > 3
|
||||
else build.await_args.kwargs.get("api_key_cloud_owner")
|
||||
)
|
||||
assert jwt_user is None
|
||||
assert key_owner is None
|
||||
|
||||
# And owner_id is NULL because the cloud-scope fence said no.
|
||||
result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""The /makerworld/* permission gate with auth enabled.
|
||||
|
||||
The gate moved out of the route signature and into the handler: the provider
|
||||
that a request actually uses comes from the body (``source_type`` on import,
|
||||
the pasted URL on resolve), and FastAPI resolves dependencies before the body
|
||||
exists, so a dependency could only ever name one provider's permission. What
|
||||
must not change is the enforcement itself, so these pin the outcomes rather
|
||||
than the wiring: anonymous callers are still refused before the body is read,
|
||||
and a signed-in user without the permission still gets a 403.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.services.model_providers.base import (
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
)
|
||||
|
||||
|
||||
async def _setup_auth_with_admin(client: AsyncClient) -> str:
|
||||
await client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True, "admin_username": "mwadmin", "admin_password": "AdminPass1!"},
|
||||
)
|
||||
login = await client.post("/api/v1/auth/login", json={"username": "mwadmin", "password": "AdminPass1!"})
|
||||
assert login.status_code == 200, login.text
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _make_user(client: AsyncClient, admin_jwt: str, *, username: str, permissions: list[str]) -> str:
|
||||
"""Create a user in a fresh group holding exactly *permissions*."""
|
||||
group = await client.post(
|
||||
"/api/v1/groups/",
|
||||
headers={"Authorization": f"Bearer {admin_jwt}"},
|
||||
json={"name": f"grp_{username}", "permissions": permissions},
|
||||
)
|
||||
assert group.status_code in (200, 201), group.text
|
||||
created = await client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {admin_jwt}"},
|
||||
json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
|
||||
)
|
||||
assert created.status_code in (200, 201), created.text
|
||||
login = await client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
|
||||
assert login.status_code == 200, login.text
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
def _fake_service(**stubs):
|
||||
svc = AsyncMock()
|
||||
svc.close = AsyncMock()
|
||||
for name, value in stubs.items():
|
||||
setattr(svc, name, AsyncMock(return_value=value))
|
||||
return svc
|
||||
|
||||
|
||||
def _import_service():
|
||||
return _fake_service(
|
||||
get_download=ProviderDownloadInfo(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107"),
|
||||
url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
|
||||
suggested_filename="cube.3mf",
|
||||
),
|
||||
download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
|
||||
)
|
||||
|
||||
|
||||
class TestAnonymousIsRefusedFirst:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_anonymous_import_is_401(self, async_client: AsyncClient):
|
||||
await _setup_auth_with_admin(async_client)
|
||||
resp = await async_client.post("/api/v1/makerworld/import", json={"model_id": 1400373})
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_anonymous_resolve_is_401(self, async_client: AsyncClient):
|
||||
await _setup_auth_with_admin(async_client)
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/resolve",
|
||||
json={"url": "https://makerworld.com/en/models/1400373"},
|
||||
)
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_anonymous_with_a_malformed_body_is_still_401_not_422(self, async_client: AsyncClient):
|
||||
"""The permission moved into the handler, but authentication stayed a
|
||||
route dependency precisely so an unauthenticated caller cannot probe
|
||||
the request schema through validation errors."""
|
||||
await _setup_auth_with_admin(async_client)
|
||||
resp = await async_client.post("/api/v1/makerworld/import", json={"nonsense": True})
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
class TestPermissionStillBites:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_view_only_user_cannot_import(self, async_client: AsyncClient):
|
||||
admin = await _setup_auth_with_admin(async_client)
|
||||
jwt = await _make_user(async_client, admin, username="mwviewer", permissions=["makerworld:view"])
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.makerworld._build_service",
|
||||
AsyncMock(return_value=_import_service()),
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/import",
|
||||
json={"model_id": 1400373},
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert "makerworld:import" in resp.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_user_without_view_cannot_resolve(self, async_client: AsyncClient):
|
||||
admin = await _setup_auth_with_admin(async_client)
|
||||
jwt = await _make_user(async_client, admin, username="mwnoview", permissions=["printers:read"])
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/resolve",
|
||||
json={"url": "https://makerworld.com/en/models/1400373"},
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert "makerworld:view" in resp.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_user_holding_the_permission_gets_through(self, async_client: AsyncClient):
|
||||
admin = await _setup_auth_with_admin(async_client)
|
||||
jwt = await _make_user(
|
||||
async_client,
|
||||
admin,
|
||||
username="mwimporter",
|
||||
permissions=["makerworld:view", "makerworld:import"],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.makerworld._build_service",
|
||||
AsyncMock(return_value=_import_service()),
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/import",
|
||||
json={"model_id": 1400373},
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["was_existing"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resolve_passes_for_a_viewer(self, async_client: AsyncClient):
|
||||
admin = await _setup_auth_with_admin(async_client)
|
||||
jwt = await _make_user(async_client, admin, username="mwviewer2", permissions=["makerworld:view"])
|
||||
|
||||
svc = _fake_service(
|
||||
resolve=ProviderResolvedModel(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
|
||||
design={"id": 1400373},
|
||||
instances=[],
|
||||
)
|
||||
)
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/resolve",
|
||||
json={"url": "https://makerworld.com/en/models/1400373"},
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["model_id"] == 1400373
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Integration tests for the media token (#3025).
|
||||
|
||||
Thirteen non-camera media routes -- library and archive thumbnails, plate
|
||||
previews, timelapses, print photos, QR codes, project covers, link icons --
|
||||
were gated by the *camera stream* token. That had two consequences, and these
|
||||
tests pin both fixes:
|
||||
|
||||
1. ``camera:view`` was a prerequisite for every image in the app. A user given
|
||||
library access to their own job folder saw broken thumbnails until they were
|
||||
also handed the live feed of the room the printer is in.
|
||||
2. A camera stream token records no principal, so those routes had no identity
|
||||
to scope by and returned any row to any holder.
|
||||
|
||||
The media token is the replacement: minted behind plain authentication, and
|
||||
identified, so each route applies the same permission and ownership rules as
|
||||
its header-authenticated siblings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
# library:read_own + archives:read_own, and deliberately NOT camera:view --
|
||||
# the reporter's exact group in #3025.
|
||||
NO_CAMERA_PERMISSIONS = [
|
||||
"library:read_own",
|
||||
"library:upload",
|
||||
"archives:read_own",
|
||||
"projects:read",
|
||||
"external_links:read",
|
||||
"printers:read",
|
||||
]
|
||||
|
||||
|
||||
async def _admin_token(async_client: AsyncClient, suffix: str) -> str:
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": f"mediaadmin{suffix}",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": f"mediaadmin{suffix}", "password": "AdminPass1!"},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _make_user(
|
||||
async_client: AsyncClient,
|
||||
admin_jwt: str,
|
||||
*,
|
||||
username: str,
|
||||
permissions: list[str],
|
||||
) -> tuple[str, int]:
|
||||
"""Create a user in a fresh group holding exactly *permissions*."""
|
||||
group = await async_client.post(
|
||||
"/api/v1/groups/",
|
||||
headers={"Authorization": f"Bearer {admin_jwt}"},
|
||||
json={"name": f"grp_{username}", "permissions": permissions},
|
||||
)
|
||||
assert group.status_code in (200, 201), group.text
|
||||
created = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {admin_jwt}"},
|
||||
json={
|
||||
"username": username,
|
||||
"password": "UserPass1!",
|
||||
"group_ids": [group.json()["id"]],
|
||||
},
|
||||
)
|
||||
assert created.status_code in (200, 201), created.text
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": username, "password": "UserPass1!"},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
return login.json()["access_token"], created.json()["id"]
|
||||
|
||||
|
||||
async def _mint_media_token(async_client: AsyncClient, jwt: str) -> str:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/media-token",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
async def _mint_camera_token(async_client: AsyncClient, jwt: str) -> str:
|
||||
response = await async_client.post(
|
||||
"/api/v1/printers/camera/stream-token",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
# The routes resolve thumbnails relative to ``settings.base_dir``, so the
|
||||
# fixtures have to write there rather than into tmp_path. Keep them in one
|
||||
# subdirectory and delete it after every test so a run leaves the tree clean.
|
||||
_THUMB_DIR = "test_thumbs_3025"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_thumbs():
|
||||
from backend.app.core.config import settings
|
||||
|
||||
yield
|
||||
shutil.rmtree(Path(settings.base_dir) / _THUMB_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
async def _library_file(db_session, owner_id: int | None, name: str) -> int:
|
||||
"""Insert a library row with a real thumbnail on disk."""
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
thumb = Path(settings.base_dir) / _THUMB_DIR / f"{name}.png"
|
||||
thumb.parent.mkdir(parents=True, exist_ok=True)
|
||||
thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
|
||||
|
||||
row = LibraryFile(
|
||||
filename=f"{name}.3mf",
|
||||
file_path=f"library/files/{name}.3mf",
|
||||
thumbnail_path=f"{_THUMB_DIR}/{thumb.name}",
|
||||
file_type="3mf",
|
||||
file_size=1234,
|
||||
created_by_id=owner_id,
|
||||
)
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(row)
|
||||
return row.id
|
||||
|
||||
|
||||
class TestTheUserWhoCouldNotSeeTheirOwnThumbnails:
|
||||
"""The reported fault: camera:view was load-bearing for every image."""
|
||||
|
||||
async def test_a_user_without_camera_view_can_mint_a_media_token(self, async_client: AsyncClient):
|
||||
admin = await _admin_token(async_client, "_mint")
|
||||
jwt, _ = await _make_user(async_client, admin, username="nocamera_mint", permissions=NO_CAMERA_PERMISSIONS)
|
||||
response = await async_client.post("/api/v1/auth/media-token", headers={"Authorization": f"Bearer {jwt}"})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["token"]
|
||||
|
||||
async def test_the_camera_token_is_still_out_of_reach_for_them(self, async_client: AsyncClient):
|
||||
"""The permission split is real, not cosmetic: the media token does not
|
||||
smuggle in camera access, and minting a camera token still costs
|
||||
camera:view."""
|
||||
admin = await _admin_token(async_client, "_nocam")
|
||||
jwt, _ = await _make_user(async_client, admin, username="nocamera_still", permissions=NO_CAMERA_PERMISSIONS)
|
||||
response = await async_client.post(
|
||||
"/api/v1/printers/camera/stream-token", headers={"Authorization": f"Bearer {jwt}"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_they_can_load_their_own_library_thumbnail(self, async_client: AsyncClient, db_session):
|
||||
admin = await _admin_token(async_client, "_own")
|
||||
jwt, user_id = await _make_user(async_client, admin, username="nocamera_own", permissions=NO_CAMERA_PERMISSIONS)
|
||||
file_id = await _library_file(db_session, user_id, "own")
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.content.startswith(b"\x89PNG")
|
||||
|
||||
|
||||
class TestTheBoundaryBetweenTheTwoTokens:
|
||||
"""Neither token is accepted where the other belongs."""
|
||||
|
||||
async def test_a_camera_stream_token_is_refused_on_a_media_route(self, async_client: AsyncClient, db_session):
|
||||
"""The inverse of verify_camwall_token's rule. A camera-stream token is
|
||||
anonymous, so honouring it here would reinstate the unowned read."""
|
||||
admin = await _admin_token(async_client, "_xcam")
|
||||
file_id = await _library_file(db_session, None, "xcam")
|
||||
camera_token = await _mint_camera_token(async_client, admin)
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={camera_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_a_media_token_is_refused_on_the_live_camera(self, async_client: AsyncClient):
|
||||
admin = await _admin_token(async_client, "_xmedia")
|
||||
media_token = await _mint_media_token(async_client, admin)
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/1/camera/snapshot?token={media_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_no_token_at_all_is_refused(self, async_client: AsyncClient, db_session):
|
||||
await _admin_token(async_client, "_notok")
|
||||
file_id = await _library_file(db_session, None, "notok")
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_a_garbage_token_is_refused(self, async_client: AsyncClient, db_session):
|
||||
await _admin_token(async_client, "_garbage")
|
||||
file_id = await _library_file(db_session, None, "garbage")
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token=not-a-real-token")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestWhoseRowsAMediaTokenCanRead:
|
||||
"""The unreported half: the old guard had no principal, so it had nothing
|
||||
to scope by. These fail against the camera-token implementation."""
|
||||
|
||||
async def test_it_cannot_read_another_users_library_thumbnail(self, async_client: AsyncClient, db_session):
|
||||
admin = await _admin_token(async_client, "_cross")
|
||||
_, alice_id = await _make_user(async_client, admin, username="alice_lib", permissions=NO_CAMERA_PERMISSIONS)
|
||||
bob_jwt, _ = await _make_user(async_client, admin, username="bob_lib", permissions=NO_CAMERA_PERMISSIONS)
|
||||
alice_file = await _library_file(db_session, alice_id, "alice")
|
||||
bob_token = await _mint_media_token(async_client, bob_jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={bob_token}")
|
||||
# 404 rather than 403 -- the same id-enumeration-proof answer
|
||||
# _ensure_library_file_visible gives on every other library route.
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_an_ownerless_file_needs_read_all(self, async_client: AsyncClient, db_session):
|
||||
"""Fail-closed, matching _ensure_library_file_visible."""
|
||||
admin = await _admin_token(async_client, "_orphan")
|
||||
jwt, _ = await _make_user(async_client, admin, username="orphan_reader", permissions=NO_CAMERA_PERMISSIONS)
|
||||
file_id = await _library_file(db_session, None, "orphan")
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_an_admin_with_read_all_still_sees_everything(self, async_client: AsyncClient, db_session):
|
||||
"""The gate must not over-correct into breaking legitimate access."""
|
||||
admin = await _admin_token(async_client, "_readall")
|
||||
_, alice_id = await _make_user(async_client, admin, username="alice_readall", permissions=NO_CAMERA_PERMISSIONS)
|
||||
alice_file = await _library_file(db_session, alice_id, "readall")
|
||||
admin_token = await _mint_media_token(async_client, admin)
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={admin_token}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestWhatTheTokenStillRequires:
|
||||
"""A media token is authentication, not authorisation -- each route keeps
|
||||
asking for the permission its resource is governed by."""
|
||||
|
||||
async def test_a_user_without_library_permission_is_refused(self, async_client: AsyncClient, db_session):
|
||||
admin = await _admin_token(async_client, "_noperm")
|
||||
jwt, user_id = await _make_user(async_client, admin, username="noperm_user", permissions=["printers:read"])
|
||||
file_id = await _library_file(db_session, user_id, "noperm")
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_a_deactivated_users_token_stops_working(self, async_client: AsyncClient, db_session):
|
||||
"""The token outlives the session it was minted in, so the principal is
|
||||
re-resolved on every request rather than trusted from mint time."""
|
||||
admin = await _admin_token(async_client, "_deact")
|
||||
jwt, user_id = await _make_user(async_client, admin, username="deact_user", permissions=NO_CAMERA_PERMISSIONS)
|
||||
file_id = await _library_file(db_session, user_id, "deact")
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
assert (await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")).status_code == 200
|
||||
|
||||
deactivate = await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers={"Authorization": f"Bearer {admin}"},
|
||||
json={"is_active": False},
|
||||
)
|
||||
assert deactivate.status_code == 200, deactivate.text
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestTheHeaderPathStillWorks:
|
||||
"""A media route is reachable with ordinary credentials too, so a fetch()
|
||||
or an API-keyed integration does not need a token at all."""
|
||||
|
||||
async def test_a_bearer_jwt_reaches_a_media_route_without_any_token(self, async_client: AsyncClient, db_session):
|
||||
admin = await _admin_token(async_client, "_bearer")
|
||||
jwt, user_id = await _make_user(async_client, admin, username="bearer_user", permissions=NO_CAMERA_PERMISSIONS)
|
||||
file_id = await _library_file(db_session, user_id, "bearer")
|
||||
|
||||
response = await async_client.get(
|
||||
f"/api/v1/library/files/{file_id}/thumbnail",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_the_header_path_is_ownership_scoped_too(self, async_client: AsyncClient, db_session):
|
||||
admin = await _admin_token(async_client, "_bearerx")
|
||||
_, alice_id = await _make_user(async_client, admin, username="alice_bearer", permissions=NO_CAMERA_PERMISSIONS)
|
||||
bob_jwt, _ = await _make_user(async_client, admin, username="bob_bearer", permissions=NO_CAMERA_PERMISSIONS)
|
||||
alice_file = await _library_file(db_session, alice_id, "alicebearer")
|
||||
|
||||
response = await async_client.get(
|
||||
f"/api/v1/library/files/{alice_file}/thumbnail",
|
||||
headers={"Authorization": f"Bearer {bob_jwt}"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestAuthDisabled:
|
||||
async def test_media_routes_stay_open_when_auth_is_off(self, async_client: AsyncClient, db_session):
|
||||
"""No setup call -- auth is off, and the routes must not start
|
||||
demanding a token that an unauthenticated install cannot mint."""
|
||||
file_id = await _library_file(db_session, None, "authoff")
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def _archive(db_session, owner_id: int | None, name: str) -> int:
|
||||
"""Insert an archive with a real thumbnail and timelapse on disk."""
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
base = Path(settings.base_dir) / _THUMB_DIR
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
(base / f"{name}_thumb.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
|
||||
(base / f"{name}_tl.mp4").write_bytes(b"\x00\x00\x00 ftypisom" + b"0" * 32)
|
||||
|
||||
row = PrintArchive(
|
||||
filename=f"{name}.3mf",
|
||||
file_path=f"archives/{name}.3mf",
|
||||
file_size=1234,
|
||||
thumbnail_path=f"{_THUMB_DIR}/{name}_thumb.png",
|
||||
timelapse_path=f"{_THUMB_DIR}/{name}_tl.mp4",
|
||||
created_by_id=owner_id,
|
||||
)
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(row)
|
||||
return row.id
|
||||
|
||||
|
||||
class TestTheArchiveMediaRoutes:
|
||||
"""The seven archive routes are where the sensitive content lives -- a
|
||||
timelapse and the finish photos are a video of someone's room. They are
|
||||
covered separately from library because the existing integration suite runs
|
||||
with auth disabled, so nothing else exercises them with auth on."""
|
||||
|
||||
async def test_an_owner_can_load_their_archive_thumbnail(self, async_client: AsyncClient, db_session):
|
||||
admin = await _admin_token(async_client, "_arcown")
|
||||
jwt, uid = await _make_user(async_client, admin, username="arc_owner", permissions=NO_CAMERA_PERMISSIONS)
|
||||
archive_id = await _archive(db_session, uid, "arcown")
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={token}")
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
async def test_another_user_cannot_load_that_thumbnail(self, async_client: AsyncClient, db_session):
|
||||
admin = await _admin_token(async_client, "_arcx")
|
||||
_, alice_id = await _make_user(async_client, admin, username="alice_arc", permissions=NO_CAMERA_PERMISSIONS)
|
||||
bob_jwt, _ = await _make_user(async_client, admin, username="bob_arc", permissions=NO_CAMERA_PERMISSIONS)
|
||||
archive_id = await _archive(db_session, alice_id, "arcx")
|
||||
bob_token = await _mint_media_token(async_client, bob_jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={bob_token}")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_another_user_cannot_load_that_timelapse(self, async_client: AsyncClient, db_session):
|
||||
"""The one that matters most: a timelapse is footage of the room the
|
||||
printer is in."""
|
||||
admin = await _admin_token(async_client, "_arctl")
|
||||
_, alice_id = await _make_user(async_client, admin, username="alice_tl", permissions=NO_CAMERA_PERMISSIONS)
|
||||
bob_jwt, _ = await _make_user(async_client, admin, username="bob_tl", permissions=NO_CAMERA_PERMISSIONS)
|
||||
archive_id = await _archive(db_session, alice_id, "arctl")
|
||||
bob_token = await _mint_media_token(async_client, bob_jwt)
|
||||
|
||||
assert (await async_client.get(f"/api/v1/archives/{archive_id}/timelapse?token={bob_token}")).status_code == 404
|
||||
|
||||
async def test_a_camera_token_reaches_no_archive_media(self, async_client: AsyncClient, db_session):
|
||||
admin = await _admin_token(async_client, "_arccam")
|
||||
archive_id = await _archive(db_session, None, "arccam")
|
||||
camera_token = await _mint_camera_token(async_client, admin)
|
||||
|
||||
for path in ("thumbnail", "timelapse", "plate-preview", "qrcode"):
|
||||
response = await async_client.get(f"/api/v1/archives/{archive_id}/{path}?token={camera_token}")
|
||||
assert response.status_code == 401, f"{path} accepted a camera token: {response.status_code}"
|
||||
|
||||
|
||||
class TestTheFlatPermissionMediaRoutes:
|
||||
"""printers/{id}/cover, external-links/{id}/icon and projects/{id}/cover-image
|
||||
have no per-row owner, so they gate on the resource's read permission."""
|
||||
|
||||
async def test_the_link_icon_needs_external_links_read(self, async_client: AsyncClient):
|
||||
admin = await _admin_token(async_client, "_icon")
|
||||
jwt, _ = await _make_user(async_client, admin, username="icon_user", permissions=["printers:read"])
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_the_link_icon_is_reachable_with_that_permission(self, async_client: AsyncClient):
|
||||
admin = await _admin_token(async_client, "_icon2")
|
||||
jwt, _ = await _make_user(async_client, admin, username="icon_user2", permissions=NO_CAMERA_PERMISSIONS)
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
|
||||
# 404 because no such link exists -- the point is that it is not 401/403.
|
||||
response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_the_printer_cover_needs_printers_read(self, async_client: AsyncClient):
|
||||
admin = await _admin_token(async_client, "_cover")
|
||||
jwt, _ = await _make_user(async_client, admin, username="cover_user", permissions=["external_links:read"])
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/1/cover?token={token}")
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_the_project_cover_needs_projects_read(self, async_client: AsyncClient):
|
||||
admin = await _admin_token(async_client, "_pcover")
|
||||
jwt, _ = await _make_user(async_client, admin, username="pcover_user", permissions=["printers:read"])
|
||||
token = await _mint_media_token(async_client, jwt)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/1/cover-image?token={token}")
|
||||
assert response.status_code == 403
|
||||
@@ -1907,3 +1907,98 @@ class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"] == "File not found"
|
||||
|
||||
|
||||
class TestLibraryAddToQueueOwnership(TestOwnershipPermissionsSetup):
|
||||
"""The bulk add-to-queue path must scope reads the way its siblings do.
|
||||
|
||||
``POST /library/files/add-to-queue`` resolved its files by raw id and gated
|
||||
only on QUEUE_CREATE, so a READ_OWN operator could queue -- and therefore
|
||||
print, and then hold the archive of -- a file a direct GET on the same id
|
||||
answers 404 for. Same shape as the slice path above.
|
||||
|
||||
An invisible row is dropped before the loop, so it reports as the plain
|
||||
"File not found" an unknown id gets: the response must not say which ids
|
||||
exist. With nothing added the route now answers 400, so the assertions read
|
||||
the reasons out of ``detail``.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def library_file_factory(self, db_session):
|
||||
_counter = [0]
|
||||
|
||||
async def _create_file(**kwargs):
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
_counter[0] += 1
|
||||
defaults = {
|
||||
"filename": f"queue_src_{_counter[0]}.gcode.3mf",
|
||||
"file_path": f"library/queue_src_{_counter[0]}.gcode.3mf",
|
||||
"file_type": "3mf",
|
||||
"file_size": 1024,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
row = LibraryFile(**defaults)
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(row)
|
||||
return row
|
||||
|
||||
return _create_file
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_operator_cannot_queue_others_library_file(
|
||||
self, async_client: AsyncClient, auth_setup, library_file_factory
|
||||
):
|
||||
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
||||
resp = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
json={"file_ids": [file.id]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
errors = resp.json()["detail"]["errors"]
|
||||
assert [e["error"] for e in errors] == ["File not found"]
|
||||
# Indistinguishable from an id that was never there.
|
||||
assert errors[0]["filename"] == "(not found)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_operator_can_queue_own_library_file(
|
||||
self, async_client: AsyncClient, auth_setup, library_file_factory
|
||||
):
|
||||
"""Control: the gate lets the owner through to the on-disk check."""
|
||||
file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
|
||||
resp = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
json={"file_ids": [file.id]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
errors = resp.json()["detail"]["errors"]
|
||||
assert [e["error"] for e in errors] == ["File not found on disk"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ownerless_file_needs_read_all(self, async_client: AsyncClient, auth_setup, library_file_factory):
|
||||
"""A row with no owner is not everyone's row -- fail closed.
|
||||
|
||||
Matches _ensure_library_file_visible, which the read routes use.
|
||||
"""
|
||||
file = await library_file_factory(created_by_id=None)
|
||||
resp = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
json={"file_ids": [file.id]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"]["errors"][0]["error"] == "File not found"
|
||||
|
||||
admin = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
||||
json={"file_ids": [file.id]},
|
||||
)
|
||||
# READ_ALL sees it and reaches the on-disk check.
|
||||
assert admin.json()["detail"]["errors"][0]["error"] == "File not found on disk"
|
||||
|
||||
@@ -636,6 +636,36 @@ class TestPrintQueueAPI:
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] == [5, -1, 2, -1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_keeps_overrides_on_a_specific_printer_job(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""An override picked for "Any P2S" survives the dialog's switch to one
|
||||
P2S (#3133). The row must keep it: when the dialog could not resolve
|
||||
every tray, the scheduler recomputes the mapping at dispatch, and without
|
||||
the override it would match the 3MF's colour again. It used to be
|
||||
dropped whenever the item had no target model.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#F5F5DC"}],
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["filament_overrides"] == [{"slot_id": 1, "type": "PLA", "color": "#F5F5DC"}]
|
||||
# The type list gates which printer of a model may take the job; a job
|
||||
# for one printer has no such choice left to make.
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
row = await db_session.get(PrintQueueItem, result["id"])
|
||||
assert row.required_filament_types is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(
|
||||
|
||||
@@ -1708,8 +1708,19 @@ class TestConfigureAMSSlotAPI:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
|
||||
"""PFUS* cloud-synced custom preset IDs are sent to the printer."""
|
||||
async def test_configure_pfus_never_reaches_tray_info_idx(self, async_client: AsyncClient, printer_factory):
|
||||
"""A PFUS* cloud setting_id is refused as tray_info_idx (#3003).
|
||||
|
||||
The printer's tray_info_idx field is 8 characters. An 18-character PFUS
|
||||
is stored truncated and acknowledged as a success -- measured on the A1
|
||||
in the #3003 bundle, which sent PFUS9ddc938fe3ab8f and read back
|
||||
PFUS9DDC. That leaves the slot holding an id nothing resolves, so the
|
||||
slicer shows "Generic" and the calibration table loses the slot too.
|
||||
A generic for the material is strictly better, and the preset reference
|
||||
survives in setting_id, which does accept a PFUS.
|
||||
|
||||
Reverses the contract #1053 pinned; see the route's own comment.
|
||||
"""
|
||||
printer = await printer_factory(name="H2D")
|
||||
|
||||
mock_client = MagicMock()
|
||||
@@ -1738,12 +1749,20 @@ class TestConfigureAMSSlotAPI:
|
||||
|
||||
assert response.status_code == 200
|
||||
call_kwargs = mock_client.ams_set_filament_setting.call_args
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
|
||||
# No tray to reuse -> generic for the material, never the raw PFUS.
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
|
||||
# The preset reference is not lost: it moves to the field that holds it.
|
||||
assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
|
||||
"""Provided PFUS* preset takes priority over slot's existing preset."""
|
||||
async def test_configure_pfus_falls_back_to_slot_preset(self, async_client: AsyncClient, printer_factory):
|
||||
"""With a PFUS refused, the slot's own resolvable preset is reused (#3003).
|
||||
|
||||
The slot already carries P4d64437 -- a local preset id, 8 characters, so
|
||||
the printer can actually store it -- for the same material. That beats a
|
||||
generic, and it is what the slot's calibration is keyed by.
|
||||
"""
|
||||
printer = await printer_factory(name="H2D")
|
||||
|
||||
mock_client = MagicMock()
|
||||
@@ -1789,13 +1808,19 @@ class TestConfigureAMSSlotAPI:
|
||||
|
||||
assert response.status_code == 200
|
||||
call_kwargs = mock_client.ams_set_filament_setting.call_args
|
||||
# Provided preset wins over slot's existing one
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
|
||||
# Slot's own storable preset wins over both the PFUS and a generic.
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
|
||||
assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
|
||||
"""Provided PFUS* preset is used even when slot has a different material."""
|
||||
async def test_configure_pfus_generic_when_slot_material_differs(self, async_client: AsyncClient, printer_factory):
|
||||
"""A slot holding a different material is not reused (#3003).
|
||||
|
||||
Slot has generic PETG, the user is configuring PLA. Neither the refused
|
||||
PFUS nor the mismatched slot can supply a filament id, so the generic
|
||||
for the requested material does.
|
||||
"""
|
||||
printer = await printer_factory(name="H2D")
|
||||
|
||||
mock_client = MagicMock()
|
||||
@@ -1834,8 +1859,8 @@ class TestConfigureAMSSlotAPI:
|
||||
|
||||
assert response.status_code == 200
|
||||
call_kwargs = mock_client.ams_set_filament_setting.call_args
|
||||
# Provided preset wins — slot's material is irrelevant
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
|
||||
assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -1873,13 +1898,19 @@ class TestConfigureAMSSlotAPI:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
|
||||
"""Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
|
||||
async def test_configure_pfus_pair_splits_into_generic_and_setting_id(
|
||||
self, async_client: AsyncClient, printer_factory
|
||||
):
|
||||
"""A PFUS sent in BOTH fields is kept only in setting_id (#3003).
|
||||
|
||||
Pins the end-to-end contract the frontend #1053 fix relies on: when the
|
||||
user configures a slot with a custom cloud preset whose cloud detail
|
||||
has filament_id=null, the frontend sends the setting_id in BOTH fields
|
||||
and the backend must not collapse either to a generic GF* ID.
|
||||
This is the shape the frontend produced when a custom cloud preset's
|
||||
detail had filament_id=null, and what #1053 pinned. The A1 measurement
|
||||
in #3003 showed where it ends up: the printer truncates tray_info_idx
|
||||
to 8 characters, so the slot resolves to nothing and the slicer falls
|
||||
back to "Generic" anyway -- the very outcome #1053 set out to avoid,
|
||||
plus a broken calibration key. Sending the generic deliberately gets
|
||||
the same slicer result honestly and keeps the slot calibratable, and
|
||||
setting_id still carries the user's preset.
|
||||
"""
|
||||
printer = await printer_factory(name="H2D")
|
||||
|
||||
@@ -1910,10 +1941,47 @@ class TestConfigureAMSSlotAPI:
|
||||
|
||||
assert response.status_code == 200
|
||||
call_kwargs = mock_client.ams_set_filament_setting.call_args
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
|
||||
assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
|
||||
# Explicitly assert no generic-collapse happened for this HT slot.
|
||||
assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_configure_pfcn_refused_as_tray_info_idx(self, async_client: AsyncClient, printer_factory):
|
||||
"""PFCN* shared / partner presets are refused the same way (#3003, #1648).
|
||||
|
||||
Same 18-character shape as a PFUS, same truncation. Polymaker's
|
||||
"(Custom)" H2D variants are the ones that reach this in the wild.
|
||||
"""
|
||||
printer = await printer_factory(name="H2D")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.ams_set_filament_setting.return_value = True
|
||||
mock_client.extrusion_cali_sel.return_value = True
|
||||
mock_client.request_status_update.return_value = True
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.raw_data = {"ams": {"ams": []}}
|
||||
|
||||
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
mock_pm.get_status.return_value = mock_status
|
||||
|
||||
response = await async_client.post(
|
||||
f"/api/v1/printers/{printer.id}/slots/0/1/configure",
|
||||
params={
|
||||
"tray_info_idx": "PFCN2a91c7d0e4b118",
|
||||
"tray_type": "PETG",
|
||||
"tray_sub_brands": "Polymaker PETG (Custom)",
|
||||
"tray_color": "0000FFFF",
|
||||
"nozzle_temp_min": 220,
|
||||
"nozzle_temp_max": 260,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call_kwargs = mock_client.ams_set_filament_setting.call_args
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
|
||||
assert call_kwargs.kwargs["setting_id"] == "PFCN2a91c7d0e4b118"
|
||||
|
||||
|
||||
class TestSkipObjectsAPI:
|
||||
|
||||
@@ -220,13 +220,18 @@ class TestProjectUrlAndCoverImage:
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_cover_image_get_uses_stream_token_gate(self):
|
||||
"""Regression guard: GET /projects/{id}/cover-image MUST be gated by
|
||||
``RequireCameraStreamTokenIfAuthEnabled`` (accepts ``?token=…`` query
|
||||
string) rather than by the bearer-token gate, because browsers can't
|
||||
attach an ``Authorization`` header to ``<img src>`` requests. Swapping
|
||||
back to the bearer gate would silently 401 every cover image when auth
|
||||
is enabled."""
|
||||
def test_cover_image_get_uses_query_token_gate(self):
|
||||
"""Regression guard: GET /projects/{id}/cover-image MUST be gated by a
|
||||
dependency that accepts ``?token=…`` in the query string rather than by
|
||||
a header-only bearer gate, because browsers can't attach an
|
||||
``Authorization`` header to ``<img src>`` requests. Swapping to a
|
||||
header-only gate would silently 401 every cover image when auth is
|
||||
enabled.
|
||||
|
||||
The token type changed in #3025 -- the route took the camera-stream
|
||||
token until then, which made ``camera:view`` a prerequisite for seeing
|
||||
a project cover -- so this pins the media gate. What it is really
|
||||
asserting is unchanged: the credential has to fit in a URL."""
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from backend.app.api.routes.projects import router
|
||||
@@ -242,21 +247,20 @@ class TestProjectUrlAndCoverImage:
|
||||
|
||||
assert cover_get is not None, "GET cover-image route missing"
|
||||
|
||||
# The route's dependant tree includes a Depends(require_camera_stream_token_if_auth_enabled())
|
||||
# The route's dependant tree includes a Depends(require_media_token_permission(...))
|
||||
# — its `call` is the inner check function returned by that factory.
|
||||
# Walk the dependant tree and assert one of the dependencies came from
|
||||
# the stream-token factory, NOT from require_permission_if_auth_enabled.
|
||||
from backend.app.core.auth import (
|
||||
require_camera_stream_token_if_auth_enabled,
|
||||
)
|
||||
# the media-token factory, NOT from require_permission_if_auth_enabled.
|
||||
from backend.app.core.auth import require_media_token_permission
|
||||
from backend.app.core.permissions import Permission
|
||||
|
||||
# The factory returns a fresh closure each call; the most reliable
|
||||
# signature is the qualified name of the function in the closure chain.
|
||||
expected_qualname = require_camera_stream_token_if_auth_enabled().__qualname__
|
||||
expected_qualname = require_media_token_permission(Permission.PROJECTS_READ).__qualname__
|
||||
|
||||
gate_qualnames = [dep.call.__qualname__ for dep in cover_get.dependant.dependencies if dep.call]
|
||||
assert expected_qualname in gate_qualnames, (
|
||||
f"GET cover-image route is not gated by RequireCameraStreamTokenIfAuthEnabled. Found: {gate_qualnames}"
|
||||
f"GET cover-image route is not gated by a media-token dependency. Found: {gate_qualnames}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -295,3 +295,28 @@ class TestQueueWithVariants:
|
||||
assert len(item_ids) == 3
|
||||
total = (await db_session.execute(select(PrintQueueVariant))).scalars().all()
|
||||
assert len(total) == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_quantity_batch_is_named_after_the_first_candidate(
|
||||
self, async_client, db_session, sliced_file_factory, printer_factory
|
||||
):
|
||||
"""A cross-model job has no archive_id and no library_file_id -- the
|
||||
candidates are the files -- so the batch name has to come from one of
|
||||
them or every such order reads "Batch" in the Batches tab (#3101)."""
|
||||
from backend.app.models.print_batch import PrintBatch
|
||||
|
||||
await printer_factory(model="H2S")
|
||||
await printer_factory(model="H2C")
|
||||
h2s = await sliced_file_factory("H2S", filename="bloom.gcode.3mf")
|
||||
h2c = await sliced_file_factory("H2C")
|
||||
|
||||
r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=4)
|
||||
assert r.status_code == 200
|
||||
|
||||
batch = (await db_session.execute(select(PrintBatch))).scalars().one()
|
||||
assert batch.name == "bloom ×4"
|
||||
# Both stay null: the row cannot name one source without disowning the
|
||||
# others, and every consumer derives progress from the items instead.
|
||||
assert batch.archive_id is None
|
||||
assert batch.library_file_id is None
|
||||
|
||||
@@ -202,7 +202,7 @@ async def test_cost_center_without_budget_is_unlimited_regardless_of_wallet_bala
|
||||
center = await db.get(CostCenter, billing_dispatch_case.ids.cost_center_id)
|
||||
center.monthly_budget = None
|
||||
center.total_budget = None
|
||||
wallet = UserWallet(user_id=user.id, balance=-100.0, currency="EUR")
|
||||
wallet = UserWallet(user_id=user.id, balance=-100.0)
|
||||
db.add(wallet)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1912,6 +1912,27 @@ class TestEncryptionRoundtrip:
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _minimal_sqlite_backup() -> bytes:
|
||||
"""A real, if empty, SQLite database to stand in for a backup's bambuddy.db.
|
||||
|
||||
Restore now refuses a bambuddy.db it cannot open, and refuses it before it
|
||||
stops services or overwrites the MFA key file -- a corrupt or truncated
|
||||
backup used to be found only by the Postgres import, which by then had
|
||||
dropped every table in the live database. These tests are about the key
|
||||
handling around the swap, so they need a file that opens; what is in it does
|
||||
not matter.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)")
|
||||
conn.commit()
|
||||
return conn.serialize()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestBackupKeyFiles:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -1980,7 +2001,7 @@ class TestBackupKeyFiles:
|
||||
# Build a minimal ZIP with a stub DB and the key file.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bambuddy.db", b"SQLite format 3")
|
||||
zf.writestr("bambuddy.db", _minimal_sqlite_backup())
|
||||
zf.writestr(".mfa_encryption_key", "test-restored-key")
|
||||
buf.seek(0)
|
||||
|
||||
@@ -2020,7 +2041,7 @@ class TestBackupKeyFiles:
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bambuddy.db", b"SQLite format 3")
|
||||
zf.writestr("bambuddy.db", _minimal_sqlite_backup())
|
||||
# Intentionally no .mfa_encryption_key entry.
|
||||
buf.seek(0)
|
||||
|
||||
@@ -2061,7 +2082,7 @@ class TestBackupKeyFiles:
|
||||
# Build ZIP with a key file that we will fail to write to DATA_DIR.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bambuddy.db", b"SQLite format 3 backup data")
|
||||
zf.writestr("bambuddy.db", _minimal_sqlite_backup())
|
||||
zf.writestr(".mfa_encryption_key", "backup-key-content")
|
||||
buf.seek(0)
|
||||
|
||||
@@ -2141,7 +2162,7 @@ class TestBackupKeyFiles:
|
||||
assert new_key != old_key
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bambuddy.db", b"SQLite format 3 backup data")
|
||||
zf.writestr("bambuddy.db", _minimal_sqlite_backup())
|
||||
zf.writestr(".mfa_encryption_key", new_key)
|
||||
buf.seek(0)
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""The app shell can read install configuration without settings:read (#3023).
|
||||
|
||||
Reporter @lonix: a user holding `cost_centers:read_own` never saw the Finance
|
||||
entry in the sidebar. The permission map was right and the route guard was
|
||||
right -- navigating to /finance directly worked and showed their balance. What
|
||||
hid it was an extra condition, `billing_enabled !== true`, read from
|
||||
GET /settings, which requires SETTINGS_READ. A non-admin gets 403 there, so the
|
||||
value arrived undefined and the entry was hidden from exactly the users the
|
||||
permission exists to serve.
|
||||
|
||||
SETTINGS_READ cannot be the price of knowing whether billing is on: it also
|
||||
grants sight of the SMTP, LDAP and MQTT credentials. Hence /settings/ui-flags,
|
||||
which asks only that the caller be signed in.
|
||||
|
||||
It is deliberately not more fields on /settings/ui-preferences. That endpoint is
|
||||
served to anyone at all, on the recorded grounds that its contents are "public
|
||||
defaults that ship with the app" (test_route_auth_coverage.py), and its field
|
||||
set is pinned by a test written to stop exactly this kind of addition. These
|
||||
fields are not defaults -- they say how this deployment is configured -- so the
|
||||
last test here pins that they did not leak into it.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
FLAGS_URL = "/api/v1/settings/ui-flags"
|
||||
_FIXTURE_PW = "Aa1!" + secrets.token_urlsafe(12) # pragma: allowlist secret
|
||||
|
||||
|
||||
async def _setup_admin(async_client: AsyncClient, username: str) -> str:
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": username, "password": _FIXTURE_PW},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _create_operator(
|
||||
async_client: AsyncClient,
|
||||
admin_token: str,
|
||||
*,
|
||||
username: str,
|
||||
permissions: list[str],
|
||||
) -> str:
|
||||
"""A non-admin holding exactly `permissions` -- never settings:read."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
grp = await async_client.post(
|
||||
"/api/v1/groups/",
|
||||
headers=headers,
|
||||
json={"name": f"ui_flags_test_{username}", "permissions": permissions},
|
||||
)
|
||||
assert grp.status_code == 201, grp.text
|
||||
user = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={
|
||||
"username": username,
|
||||
"password": _FIXTURE_PW,
|
||||
"role": "user",
|
||||
"group_ids": [grp.json()["id"]],
|
||||
},
|
||||
)
|
||||
assert user.status_code == 201, user.text
|
||||
assert user.json()["is_admin"] is False
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": username, "password": _FIXTURE_PW},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestTheUserTheEndpointExistsFor:
|
||||
"""A non-admin with cost_centers:read_own and nothing else."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_they_can_read_the_flags(self, async_client: AsyncClient):
|
||||
admin = await _setup_admin(async_client, "flagadmin1")
|
||||
op = await _create_operator(async_client, admin, username="flagop1", permissions=["cost_centers:read_own"])
|
||||
|
||||
resp = await async_client.get(FLAGS_URL, headers={"Authorization": f"Bearer {op}"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "billing_enabled" in resp.json()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_they_still_cannot_read_settings(self, async_client: AsyncClient):
|
||||
"""The fix must not have widened SETTINGS_READ to get there."""
|
||||
admin = await _setup_admin(async_client, "flagadmin2")
|
||||
op = await _create_operator(async_client, admin, username="flagop2", permissions=["cost_centers:read_own"])
|
||||
|
||||
resp = await async_client.get("/api/v1/settings/", headers={"Authorization": f"Bearer {op}"})
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_billing_enabled_carries_the_configured_value(self, async_client: AsyncClient, db_session):
|
||||
"""The whole point: the sidebar tests this for `true`, so it has to be
|
||||
the real value and a real bool, not a truthy string."""
|
||||
admin = await _setup_admin(async_client, "flagadmin3")
|
||||
op = await _create_operator(async_client, admin, username="flagop3", permissions=["cost_centers:read_own"])
|
||||
db_session.add(Settings(key="billing_enabled", value="true"))
|
||||
await db_session.commit()
|
||||
|
||||
resp = await async_client.get(FLAGS_URL, headers={"Authorization": f"Bearer {op}"})
|
||||
assert resp.json()["billing_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestTheBoundaryItDraws:
|
||||
"""Signed in is required; settings:read is not."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_anonymous_caller_is_refused_when_auth_is_on(self, async_client: AsyncClient):
|
||||
"""This is the reason it is a separate endpoint rather than four more
|
||||
fields on the public one."""
|
||||
await _setup_admin(async_client, "flagadmin4")
|
||||
|
||||
resp = await async_client.get(FLAGS_URL)
|
||||
assert resp.status_code in (401, 403), resp.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_it_answers_when_auth_is_switched_off(self, async_client: AsyncClient):
|
||||
"""An install with no auth has no user to authenticate, and the shell
|
||||
still has to render. require_auth_if_enabled returns None there."""
|
||||
resp = await async_client.get(FLAGS_URL)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestWhatItExposes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_field_set_is_exactly_these_four(self, async_client: AsyncClient):
|
||||
"""Pinned like the /ui-preferences set: anything added here is readable
|
||||
by every signed-in user, so adding one should require editing this."""
|
||||
resp = await async_client.get(FLAGS_URL)
|
||||
assert set(resp.json().keys()) == {
|
||||
"billing_enabled",
|
||||
"user_notifications_enabled",
|
||||
"currency",
|
||||
"check_updates",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_credential_ever_appears(self, async_client: AsyncClient, db_session):
|
||||
for i, key in enumerate(
|
||||
("smtp_password", "ldap_bind_password", "mqtt_password", "ha_token", "prometheus_token")
|
||||
):
|
||||
db_session.add(Settings(key=key, value=f"SECRET_VALUE_{i}_DO_NOT_LEAK"))
|
||||
await db_session.commit()
|
||||
|
||||
body = (await async_client.get(FLAGS_URL)).text
|
||||
assert "DO_NOT_LEAK" not in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_public_endpoint_did_not_gain_them(self, async_client: AsyncClient):
|
||||
"""These describe the deployment, not app defaults, so they must not
|
||||
have been added to the endpoint that serves anyone at all."""
|
||||
public = (await async_client.get("/api/v1/settings/ui-preferences")).json()
|
||||
assert "billing_enabled" not in public
|
||||
assert "user_notifications_enabled" not in public
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Integration tests for reusable slicer download tokens (#3029).
|
||||
|
||||
The "Slice" action hands a URL to a *separate process* -- Bambu Studio or
|
||||
OrcaSlicer, launched through a protocol handler that cannot carry an
|
||||
``Authorization`` header. Until this fix the token in that URL was consumed by
|
||||
the first request that reached the endpoint, which made the handoff dependent
|
||||
on the slicer fetching the URL exactly once. Nothing guarantees that: Bambu
|
||||
Studio's downloader retries three times after a failed attempt, transfers get
|
||||
resumed, on-access scanners fetch. Whichever party arrived first won, and the
|
||||
slicer was handed a 403.
|
||||
|
||||
So the three protocol-handler downloads now accept their token for the rest of
|
||||
its five-minute TTL. Everything else about the token is unchanged, and these
|
||||
tests pin the difference in both directions: the second fetch works, and the
|
||||
token is still refused for the wrong resource, after expiry, and when unknown.
|
||||
|
||||
The two *browser* downloads that share the same primitive stay one-shot, and
|
||||
are pinned here too -- the prepared printer bundle is deleted once streamed, so
|
||||
reuse there could only ever mean a 404 with a misleading cause.
|
||||
|
||||
The second half covers a fault found while checking the first: the auth
|
||||
middleware matches ``PUBLIC_API_PATTERNS`` by substring, and the source-3MF
|
||||
route's segment is ``source-dl`` -- which does not contain ``/dl/``. With auth
|
||||
enabled the middleware rejected the slicer's header-less request before the
|
||||
route's own token check ever ran.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
# Same reasoning as #3025's fixtures: the routes resolve paths relative to
|
||||
# ``settings.base_dir``, which under test is the project root, so everything
|
||||
# goes in one subdirectory that is removed after each test.
|
||||
_FILE_DIR = "test_files_3029"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_files():
|
||||
from backend.app.core.config import settings
|
||||
|
||||
yield
|
||||
shutil.rmtree(Path(settings.base_dir) / _FILE_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
def _write(name: str, body: bytes) -> str:
|
||||
"""Write a file under the scratch dir and return its base_dir-relative path."""
|
||||
from backend.app.core.config import settings
|
||||
|
||||
path = Path(settings.base_dir) / _FILE_DIR / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(body)
|
||||
return f"{_FILE_DIR}/{name}"
|
||||
|
||||
|
||||
async def _library_file(db_session, name: str, body: bytes = b"solid test\nendsolid test\n") -> int:
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
row = LibraryFile(
|
||||
filename=f"{name}.stl",
|
||||
file_path=_write(f"{name}.stl", body),
|
||||
file_type="stl",
|
||||
file_size=len(body),
|
||||
)
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(row)
|
||||
return row.id
|
||||
|
||||
|
||||
async def _archive(db_session, name: str, *, with_source: bool = False) -> int:
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
row = PrintArchive(
|
||||
filename=f"{name}.3mf",
|
||||
file_path=_write(f"{name}.3mf", b"PK\x03\x04sliced"),
|
||||
file_size=13,
|
||||
source_3mf_path=_write(f"{name}_source.3mf", b"PK\x03\x04source") if with_source else None,
|
||||
)
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(row)
|
||||
return row.id
|
||||
|
||||
|
||||
async def _stored_token(resource_type: str, resource_id: int, *, expires_in_minutes: int = 5) -> str:
|
||||
"""Insert a slicer token directly, so expiry can be set to the past."""
|
||||
import secrets
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
|
||||
|
||||
token = secrets.token_urlsafe(24)
|
||||
async with async_session() as db:
|
||||
db.add(
|
||||
AuthEphemeralToken(
|
||||
token=token,
|
||||
token_type=TokenType.SLICER_DOWNLOAD,
|
||||
nonce=f"{resource_type}:{resource_id}",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=expires_in_minutes),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
class TestTheSlicerThatFetchesTwice:
|
||||
"""The reported fault: the second fetch of the same URL got a 403, and the
|
||||
slicer wrote that JSON body out as the model."""
|
||||
|
||||
async def test_a_library_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
|
||||
file_id = await _library_file(db_session, "reused")
|
||||
minted = await async_client.post(f"/api/v1/library/files/{file_id}/slicer-token")
|
||||
assert minted.status_code == 200, minted.text
|
||||
token = minted.json()["token"]
|
||||
|
||||
url = f"/api/v1/library/files/{file_id}/dl/{token}/reused.stl"
|
||||
first = await async_client.get(url)
|
||||
assert first.status_code == 200, first.text
|
||||
assert first.content.startswith(b"solid test")
|
||||
|
||||
second = await async_client.get(url)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.content == first.content
|
||||
|
||||
third = await async_client.get(url)
|
||||
assert third.status_code == 200
|
||||
|
||||
async def test_an_archive_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
|
||||
archive_id = await _archive(db_session, "arc_reused")
|
||||
minted = await async_client.post(f"/api/v1/archives/{archive_id}/slicer-token")
|
||||
assert minted.status_code == 200, minted.text
|
||||
token = minted.json()["token"]
|
||||
|
||||
url = f"/api/v1/archives/{archive_id}/dl/{token}/arc_reused.3mf"
|
||||
assert (await async_client.get(url)).status_code == 200
|
||||
assert (await async_client.get(url)).status_code == 200
|
||||
|
||||
async def test_a_source_3mf_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
|
||||
archive_id = await _archive(db_session, "src_reused", with_source=True)
|
||||
minted = await async_client.post(f"/api/v1/archives/{archive_id}/source-slicer-token")
|
||||
assert minted.status_code == 200, minted.text
|
||||
token = minted.json()["token"]
|
||||
|
||||
url = f"/api/v1/archives/{archive_id}/source-dl/{token}/src_reused.3mf"
|
||||
first = await async_client.get(url)
|
||||
assert first.status_code == 200, first.text
|
||||
assert (await async_client.get(url)).status_code == 200
|
||||
|
||||
|
||||
class TestWhatTheReusableTokenStillRefuses:
|
||||
"""Reuse is the only thing that changed. Resource binding and expiry are
|
||||
what make these URLs safe to hand out, so each is checked explicitly."""
|
||||
|
||||
async def test_it_is_still_bound_to_one_file(self, async_client: AsyncClient, db_session):
|
||||
mine = await _library_file(db_session, "bound_mine")
|
||||
theirs = await _library_file(db_session, "bound_theirs")
|
||||
token = (await async_client.post(f"/api/v1/library/files/{mine}/slicer-token")).json()["token"]
|
||||
|
||||
wrong = await async_client.get(f"/api/v1/library/files/{theirs}/dl/{token}/bound_theirs.stl")
|
||||
assert wrong.status_code == 403
|
||||
|
||||
# And the rejected attempt must not have burned the token for its own file.
|
||||
right = await async_client.get(f"/api/v1/library/files/{mine}/dl/{token}/bound_mine.stl")
|
||||
assert right.status_code == 200
|
||||
|
||||
async def test_an_archive_token_does_not_open_the_source_3mf(self, async_client: AsyncClient, db_session):
|
||||
"""The two archive downloads are separate resource keys on the same id."""
|
||||
archive_id = await _archive(db_session, "cross_key", with_source=True)
|
||||
token = (await async_client.post(f"/api/v1/archives/{archive_id}/slicer-token")).json()["token"]
|
||||
|
||||
crossed = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/{token}/cross_key.3mf")
|
||||
assert crossed.status_code == 403
|
||||
|
||||
async def test_an_expired_token_is_refused(self, async_client: AsyncClient, db_session):
|
||||
file_id = await _library_file(db_session, "stale")
|
||||
token = await _stored_token("library", file_id, expires_in_minutes=-1)
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/dl/{token}/stale.stl")
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_an_unknown_token_is_refused(self, async_client: AsyncClient, db_session):
|
||||
file_id = await _library_file(db_session, "unknown")
|
||||
response = await async_client.get(f"/api/v1/library/files/{file_id}/dl/not-a-token/unknown.stl")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestTheOneShotDownloadsStayOneShot:
|
||||
"""Reuse was granted per endpoint, not to the primitive. The two browser
|
||||
downloads keep consuming their token, and the default is still to consume
|
||||
-- a new caller has to ask for reuse deliberately."""
|
||||
|
||||
async def test_the_primitive_still_consumes_by_default(self, async_client: AsyncClient, db_session):
|
||||
from backend.app.core.auth import verify_slicer_download_token
|
||||
|
||||
token = await _stored_token("printer-files", 7)
|
||||
assert await verify_slicer_download_token(token, "printer-files", 7) is True
|
||||
assert await verify_slicer_download_token(token, "printer-files", 7) is False
|
||||
|
||||
async def test_a_reusable_check_does_not_consume(self, async_client: AsyncClient, db_session):
|
||||
from backend.app.core.auth import verify_slicer_download_token
|
||||
|
||||
token = await _stored_token("library", 7)
|
||||
assert await verify_slicer_download_token(token, "library", 7, single_use=False) is True
|
||||
assert await verify_slicer_download_token(token, "library", 7, single_use=False) is True
|
||||
# ...and a consuming check on the same row still works, so the row is
|
||||
# not a different kind of token -- only the redemption differs.
|
||||
assert await verify_slicer_download_token(token, "library", 7) is True
|
||||
assert await verify_slicer_download_token(token, "library", 7, single_use=False) is False
|
||||
|
||||
async def test_the_archive_timelapse_download_is_still_single_use(self, async_client: AsyncClient, db_session):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
row = PrintArchive(
|
||||
filename="tl.3mf",
|
||||
file_path=_write("tl.3mf", b"PK\x03\x04"),
|
||||
file_size=4,
|
||||
timelapse_path=_write("tl.mp4", b"\x00\x00\x00 ftypisom"),
|
||||
)
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(row)
|
||||
|
||||
token = (await async_client.post(f"/api/v1/archives/{row.id}/media-download-token")).json()["token"]
|
||||
url = f"/api/v1/archives/{row.id}/media/dl/{token}/tl.mp4"
|
||||
assert (await async_client.get(url)).status_code == 200
|
||||
assert (await async_client.get(url)).status_code == 403
|
||||
|
||||
|
||||
class TestTheSourceDownloadReachesItsHandler:
|
||||
"""``PUBLIC_API_PATTERNS`` is matched with ``in path``, and ``source-dl/``
|
||||
does not contain ``/dl/``. With auth enabled the middleware answered 401
|
||||
before the route's token check ran, so "Open source 3MF in slicer" could
|
||||
never work -- the slicer has no header to send."""
|
||||
|
||||
async def test_the_pattern_list_covers_the_source_route(self):
|
||||
from backend.app.main import PUBLIC_API_PATTERNS
|
||||
|
||||
path = "/api/v1/archives/5/source-dl/tok/model.3mf"
|
||||
assert not any(p in path for p in ["/dl/"]), "guard: /dl/ must not cover source-dl"
|
||||
assert any(p in path for p in PUBLIC_API_PATTERNS)
|
||||
|
||||
async def test_the_source_download_works_with_auth_enabled(self, async_client: AsyncClient, db_session):
|
||||
setup = await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True, "admin_username": "slicer3029", "admin_password": "AdminPass1!"},
|
||||
)
|
||||
assert setup.status_code in (200, 201), setup.text
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "slicer3029", "password": "AdminPass1!"},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
jwt = login.json()["access_token"]
|
||||
|
||||
archive_id = await _archive(db_session, "authed_source", with_source=True)
|
||||
minted = await async_client.post(
|
||||
f"/api/v1/archives/{archive_id}/source-slicer-token",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert minted.status_code == 200, minted.text
|
||||
token = minted.json()["token"]
|
||||
|
||||
# No Authorization header -- exactly what the protocol handler sends.
|
||||
response = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/{token}/authed_source.3mf")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.content == b"PK\x03\x04source"
|
||||
|
||||
async def test_a_bad_token_is_refused_by_the_handler_not_the_middleware(
|
||||
self, async_client: AsyncClient, db_session
|
||||
):
|
||||
"""403, not 401: the middleware stepping aside must not make the route
|
||||
public, and the distinction is what proves the handler ran."""
|
||||
setup = await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True, "admin_username": "slicer3029b", "admin_password": "AdminPass1!"},
|
||||
)
|
||||
assert setup.status_code in (200, 201), setup.text
|
||||
archive_id = await _archive(db_session, "refused_source", with_source=True)
|
||||
|
||||
response = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/nope/refused_source.3mf")
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,136 @@
|
||||
"""The kiosk has to be told when a spool's colour name is only a stand-in (#3090).
|
||||
|
||||
SpoolBuddy showed "Unknown color" for spools Bambuddy names perfectly well. The
|
||||
name is not in the spool record: Bambu's RFID tags frequently carry none, and
|
||||
Spoolman has no colour-name field at all, so the frontend resolves the swatch's
|
||||
hex against the colour catalog instead. The kiosk was reading the raw column.
|
||||
|
||||
That is a frontend fix, except for one thing the frontend cannot work out on
|
||||
its own. In Spoolman mode ``_map_spoolman_spool`` puts the spool's *subtype*
|
||||
in ``color_name`` when nothing is stored, so the kiosk receives "Silk+" — a
|
||||
plausible-looking string that would beat the catalog if it were taken at face
|
||||
value. ``color_name_is_synthesized`` is how the backend already marks that,
|
||||
and these tests pin it onto the tag-matched broadcast, which is the one place
|
||||
the kiosk learns about a scanned spool.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
SPOOLBUDDY_API = "/api/v1/spoolbuddy"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def spoolman_enabled(db_session: AsyncSession):
|
||||
db_session.add(Settings(key="spoolman_enabled", value="true"))
|
||||
db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def spoolman_disabled(db_session: AsyncSession):
|
||||
db_session.add(Settings(key="spoolman_enabled", value="false"))
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
def _spoolman_spool_without_a_colour_name() -> dict:
|
||||
"""A Silk+ roll as Spoolman holds it: a swatch, and nowhere to put a name."""
|
||||
return {
|
||||
"id": 38,
|
||||
"filament": {
|
||||
"material": "PLA",
|
||||
"name": "PLA Silk+",
|
||||
"color_hex": "D02727", # Candy Red, in the colour catalog
|
||||
"weight": 1000.0,
|
||||
"spool_weight": 250.0,
|
||||
"vendor": {"name": "Bambu Lab"},
|
||||
},
|
||||
"used_weight": 0.0,
|
||||
"archived": False,
|
||||
"registered": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def _mock_spoolman_client(spool: dict) -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.base_url = "http://spoolman.local:7912"
|
||||
client.get_spools = AsyncMock(return_value=[spool])
|
||||
client.find_spool_by_tag = AsyncMock(return_value=spool)
|
||||
client.merge_spool_extra = AsyncMock(return_value={})
|
||||
return client
|
||||
|
||||
|
||||
async def _scan(async_client: AsyncClient) -> dict:
|
||||
"""Scan a tag and return the broadcast the kiosk would receive."""
|
||||
with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
|
||||
mock_ws.broadcast = AsyncMock()
|
||||
resp = await async_client.post(
|
||||
f"{SPOOLBUDDY_API}/nfc/tag-scanned",
|
||||
json={
|
||||
"device_id": "sb-test",
|
||||
"tag_uid": "AABB1122334455FF",
|
||||
"tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_ws.broadcast.assert_called_once()
|
||||
return mock_ws.broadcast.call_args[0][0]
|
||||
|
||||
|
||||
class TestTheScanBroadcastSaysWhereTheNameCameFrom:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_spoolman_spool_is_marked_as_having_no_real_name(self, async_client: AsyncClient, spoolman_enabled):
|
||||
"""Spoolman keeps no colour name, so what arrives is the subtype."""
|
||||
spool = _spoolman_spool_without_a_colour_name()
|
||||
client = _mock_spoolman_client(spool)
|
||||
with (
|
||||
patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
|
||||
patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
|
||||
):
|
||||
msg = await _scan(async_client)
|
||||
|
||||
assert msg["type"] == "spoolbuddy_tag_matched"
|
||||
# The stand-in is still sent — it is the only thing there, and a kiosk
|
||||
# that cannot resolve the hex should show something.
|
||||
assert msg["spool"]["color_name"] == "Silk+"
|
||||
assert msg["spool"]["color_name_is_synthesized"] is True, (
|
||||
'without this the kiosk shows "Silk+" where the catalog knows the colour'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_local_spool_is_never_marked_synthesized(self, async_client: AsyncClient, spoolman_disabled):
|
||||
"""Local inventory stores what the user or their tag set, or nothing.
|
||||
|
||||
A name that is present is a real one, and an absent one must stay
|
||||
absent rather than acquiring a stand-in — the kiosk resolves the empty
|
||||
case from the swatch, and cannot do that for a name it is told to
|
||||
trust.
|
||||
"""
|
||||
spool = MagicMock()
|
||||
spool.id = 38
|
||||
spool.material = "PLA"
|
||||
spool.subtype = "Silk+"
|
||||
spool.color_name = None
|
||||
spool.rgba = "D02727FF"
|
||||
spool.brand = "Bambu Lab"
|
||||
spool.label_weight = 1000
|
||||
spool.core_weight = 250
|
||||
spool.weight_used = 0
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.spoolbuddy.get_spool_by_tag",
|
||||
new_callable=AsyncMock,
|
||||
return_value=spool,
|
||||
):
|
||||
msg = await _scan(async_client)
|
||||
|
||||
assert msg["type"] == "spoolbuddy_tag_matched"
|
||||
assert msg["spool"]["color_name"] is None
|
||||
assert msg["spool"]["color_name_is_synthesized"] is False
|
||||
@@ -2083,6 +2083,119 @@ class TestLinkTagDuplicate:
|
||||
detail = resp.json()["detail"]
|
||||
assert "42" in str(detail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_409_is_the_same_structured_detail_as_the_built_in_route(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
"""#3110: one shape for both inventory modes, not two prose sentences.
|
||||
|
||||
The built-in route said "already linked to another active spool" and
|
||||
named nobody; this one named the spool but only inside a sentence. A
|
||||
client had to parse prose, and a different sentence per mode.
|
||||
"""
|
||||
resp = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/spools/99/tag",
|
||||
json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["code"] == "tag_already_linked"
|
||||
assert detail["spool_id"] == 42
|
||||
assert detail["field"] == "tray_uuid"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_field_follows_the_precedence_the_tag_itself_uses(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
"""tray_uuid wins over tag_uid when both are sent, so `field` says so."""
|
||||
mock_spoolman_client.get_all_spools.return_value = [
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": '"AABBCCDDEEFF0011"'}}
|
||||
]
|
||||
|
||||
resp = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/spools/99/tag",
|
||||
json={"tag_uid": "AABBCCDDEEFF0011"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["field"] == "tag_uid"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_duplicate_holders_yield_the_lowest_id(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
"""Spoolman has no unique constraint on extra.tag either.
|
||||
|
||||
Whichever row the scan reached first was an arbitrary answer; the
|
||||
built-in route names the lowest id, so this one does too.
|
||||
"""
|
||||
tag = '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'
|
||||
mock_spoolman_client.get_all_spools.return_value = [
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 77, "extra": {"tag": tag}},
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": tag}},
|
||||
]
|
||||
|
||||
resp = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/spools/99/tag",
|
||||
json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["spool_id"] == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_malformed_row_after_the_holder_does_not_sink_the_request(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
"""extra is free-form and edited outside Bambuddy.
|
||||
|
||||
Naming the lowest id means reading every row, where the old loop
|
||||
stopped at its first match -- so a row whose extra.tag is a JSON null
|
||||
(which .get("tag", "") hands back as None, not the default) sits
|
||||
between the caller and their 409 in a way it never used to.
|
||||
"""
|
||||
tag = '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'
|
||||
mock_spoolman_client.get_all_spools.return_value = [
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": tag}},
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 55, "extra": {"tag": None}},
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 56, "extra": {"tag": 12345}},
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 57, "extra": None},
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 58, "extra": []},
|
||||
]
|
||||
|
||||
resp = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/spools/99/tag",
|
||||
json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["spool_id"] == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_malformed_row_is_not_itself_read_as_a_holder(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
"""A link with no real conflict still succeeds past those rows."""
|
||||
mock_spoolman_client.get_all_spools.return_value = [
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 55, "extra": {"tag": None}},
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 56, "extra": {"tag": 12345}},
|
||||
{**SAMPLE_SPOOLMAN_SPOOL, "id": 57, "extra": None},
|
||||
]
|
||||
|
||||
resp = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/spools/42/tag",
|
||||
json={"tag_uid": "AABBCCDD112233"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_spoolman_client.update_spool_full.assert_called_once()
|
||||
|
||||
|
||||
class TestSpoolmanInventoryUpdateCoreWeight:
|
||||
"""core_weight is accepted for schema parity but not persisted — any value should be accepted."""
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Unit tests for the firmware presence bit helper.
|
||||
|
||||
#3084: a slot can read ``exists=True, state=9`` — the bit says a spool is in
|
||||
it, the state says the opposite — because ``apply_tray_exist_bits`` writes the
|
||||
9 itself and never takes it back. Everything downstream has to know which of
|
||||
the two to believe, and this helper is the single place that says so.
|
||||
"""
|
||||
|
||||
from backend.app.services.ams_slot_presence import spool_present
|
||||
|
||||
|
||||
class TestSpoolPresent:
|
||||
def test_the_bit_is_reported_as_it_stands(self):
|
||||
assert spool_present({"id": 0, "exists": True}) is True
|
||||
assert spool_present({"id": 0, "exists": False}) is False
|
||||
|
||||
def test_the_bit_answers_over_a_contradicting_state(self):
|
||||
# The #3084 slot: non-Bambu spool swapped in, so the bit is set, while
|
||||
# the 9 Bambuddy stamped on the slot when it was briefly empty is still
|
||||
# sitting there.
|
||||
assert spool_present({"id": 0, "exists": True, "state": 9}) is True
|
||||
# And the converse — a spool pulled from a slot the firmware last
|
||||
# described as loaded.
|
||||
assert spool_present({"id": 0, "exists": False, "state": 11}) is False
|
||||
|
||||
def test_a_tray_with_no_annotation_answers_nothing(self):
|
||||
# vt_tray entries and the VP bridge's cache carry no presence bit, so
|
||||
# callers have to fall back to their own reading rather than be handed
|
||||
# a guess dressed up as firmware's answer.
|
||||
assert spool_present({"id": 0, "state": 11, "tray_type": "PLA"}) is None
|
||||
assert spool_present({}) is None
|
||||
|
||||
def test_a_non_bool_exists_is_not_a_presence_bit(self):
|
||||
# Only apply_tray_exist_bits writes this key, and it writes a bool.
|
||||
# Anything else reached the dict some other way and is not firmware's
|
||||
# answer — 0 and "" would otherwise read as a confident "empty".
|
||||
assert spool_present({"exists": 0}) is None
|
||||
assert spool_present({"exists": ""}) is None
|
||||
assert spool_present({"exists": "true"}) is None
|
||||
assert spool_present({"exists": None}) is None
|
||||
|
||||
def test_a_missing_tray_answers_nothing(self):
|
||||
assert spool_present(None) is None
|
||||
assert spool_present([]) is None
|
||||
assert spool_present("tray") is None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for ``services/bambu_cloud_credentials`` — the credential seam.
|
||||
|
||||
The read paths are covered indirectly by the cloud-token expiry and
|
||||
migration suites; these pin the write path that review blocker 5 hinged on:
|
||||
``mark_cloud_token_invalid`` must record a rejection for *both* identity
|
||||
shapes, because auth-disabled single-user installs (the default) hold their
|
||||
token in global ``Settings`` — ``user_id=None`` is a real, expected input,
|
||||
not a degenerate one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services import bambu_cloud_credentials as creds
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
CLOUD_TOKEN_INVALID_KEY,
|
||||
mark_cloud_token_invalid,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class _SharedSessionCtx:
|
||||
"""Route ``mark`` through the fixture's in-memory session: the function
|
||||
normally opens its own session against the configured database, which in
|
||||
tests is a different SQLite than ``db_session``'s in-memory one."""
|
||||
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
async def __aenter__(self):
|
||||
return self._session
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def shared_session(db_session, monkeypatch):
|
||||
monkeypatch.setattr(creds, "async_session", lambda: _SharedSessionCtx(db_session))
|
||||
|
||||
|
||||
async def _make_user(db, username: str = "cred-user") -> User:
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=get_password_hash("AdminPass1!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def test_mark_sets_the_per_user_flag(db_session):
|
||||
"""user_id set → the rejection lands on that user's column."""
|
||||
user = await _make_user(db_session)
|
||||
|
||||
await mark_cloud_token_invalid(user.id)
|
||||
await db_session.refresh(user)
|
||||
|
||||
assert user.cloud_token_invalid_at is not None
|
||||
|
||||
|
||||
async def test_mark_none_writes_the_global_settings_flag(db_session):
|
||||
"""user_id=None (auth-disabled install) → the global ``Settings`` row.
|
||||
A second call updates the existing row rather than adding another."""
|
||||
await mark_cloud_token_invalid(None)
|
||||
|
||||
result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 1
|
||||
# Stored value parses as ISO — the status endpoints compare it as a date.
|
||||
datetime.fromisoformat(rows[0].value)
|
||||
|
||||
first_value = rows[0].value
|
||||
await mark_cloud_token_invalid(None)
|
||||
result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].value >= first_value
|
||||
|
||||
|
||||
async def test_mark_is_best_effort(db_session, monkeypatch):
|
||||
"""A bookkeeping failure must never replace the 401 the caller needs to
|
||||
see — the function swallows everything."""
|
||||
|
||||
class _Boom:
|
||||
async def __aenter__(self):
|
||||
raise RuntimeError("db gone")
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(creds, "async_session", lambda: _Boom())
|
||||
|
||||
# Must not raise.
|
||||
await mark_cloud_token_invalid(None)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user