mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
cfecfa360ec444e73c66e5e23dbf7ee209f3aeca
429
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d227d42272 |
Let a print with no 3MF be given its filament weight (issue #1820)
When the sliced file stays somewhere Bambuddy cannot read, the archive is built from the printer's report alone and carries no weight. Nothing could supply one afterwards: rescan reads the figure out of the 3MF, and that archive has no file to read. The reporter's H2S print left 46.16 g on the spool with nothing recording it, and he corrected Spoolman by hand. Edit Archive now has a Filament used (g) field. It is written to the archive's most recent run as well, because the Projects roll-up and the Prometheus counter sum PrintLogEntry rather than the cards - correcting only the archive would fix the display and leave every aggregate reading the old figure, or none at all. But not over a figure the run measured for itself. A run's grams come from the tracked spool delta when there is one and only fall back to copying the archive's estimate when there is not, so mirroring unconditionally would overwrite a measurement with a typed estimate. The mirror now takes a run that has no figure, or one holding exactly what this archive held - which also makes the undo complete, since clearing the archive clears the copy it made and leaves a measured run alone. The field is text rather than a number input. A number input reports an empty string for anything the browser judges malformed, a decimal comma in a locale that does not expect one included, and that reads here as "the user cleared it" - it would have wiped a good figure while the field still showed what was typed. Filtering on the way in keeps what is displayed and what would be sent the same string, and clamps it to the range the API accepts: this modal has no error surface, so a refused save looks like nothing happened at all. Saving also invalidates the archive's runs query. The Print Log this modal renders at its top reads them separately and kept serving the pre-edit row, so a correction looked like it had not taken - true for the status and failure-reason mirrors since #1444 as well. Second half of the same report: the internal-storage probe (#2856) logged which file it found but not where. On a printer that keeps uploads for weeks - the reporter has months of them in /cache - a reprint of a name that was re-sliced but never re-sent can match an older copy, and without the directory that mismatch is invisible rather than merely rare. The download helper returns the path that served the file instead of a bare flag; every caller only ever tested it for truth. |
||
|
|
607b34e94d |
Check the card before writing a print off as internal-storage-only (issue #2856)
A print's dispatch says where the printer put the sliced file: ftp://<name> for external storage, brtc://emmc/<name> for internal. Since that there is then no file to find at any path. That is where the printer chose to put it, which is not the same as where port 990 can read it. The reporter's H2D - firmware 01.03.00.00, card in the slot - reports brtc://emmc and keeps the same file under /cache: his log has every print from 08-12 downloading from there, 19 MB included, until the skip landed and two days of archives came out as a name and nothing else. #2780's P2S and H2C really did 550 on every path, so both are true and the URL alone cannot tell them apart. So ask the printer rather than the model. The dispatch names the exact file, which turns the question into one connection walking five directories - against the sweep's ~110, which is the cost that made skipping worth doing. A hit archives normally and is shared with the cover endpoint; a miss keeps #2780's fallback archive and its reason, so the archives banner still explains itself. Not probed when the printer reports an empty slot, or while its file service is in TLS cool-off: both have already answered the question. The connection diagnostic asked the same question off the URL and warned that the last print was out of reach. On this reporter's printer that warning would have sent him to a setting that was already right, so it now probes too - by directory listing, since the file it is asking about can be tens of megabytes and the answer is a yes or a no. Capped at 6s to stay inside the support bundle's per-printer budget, and "could not check" leaves the warning standing. The probe filename arrives over MQTT and becomes both a remote path and a local temp filename, so names carrying separators, traversal or control characters are declined rather than cleaned. |
||
|
|
6488a33488 |
Stop a print with no 3MF borrowing another model's data (#2843)
H2-series and P2S firmware keeps a slicer-sent file on internal eMMC. Port 990 serves external storage only, so there is no file to fetch, and the print becomes an archive with no 3MF -- the ordinary outcome for anyone who sends from Bambu Studio rather than through Bambuddy. Confirmed on the maintainer's own machines: an H2C and an H2D both dispatched brtc://emmc for the same model one minute apart, while an X1C sent ftp:// for it. Three separate defects live in what happens next. The first is the serious one. An archive with no 3MF keeps the path the printer is executing as its filename, and on a sliced job that is always Metadata/plate_1.gcode. The fallback that looks for the same model in the Library or among earlier prints took its search term from there, so it searched for `plate_1` -- a name every Bambu print in existence has -- and matched on a substring, so it also matched any name merely ending that way. On the H2D a 1.6 g Cube resolved to lid_plate_1.gcode.3mf and was costed at 207 g across three real spools. It was not confined to plate names either: in the same database `Bank.3mf` matched "Piggo the piggy bank", and `x1c.gcode.3mf` matched "slice-test-x1c". The matcher now takes the model name the printer reports when the filename is only a plate path, refuses a bare plate stem rather than searching for it, and anchors to a whole filename with LIKE metacharacters escaped, because `_` is a wildcard and model names are full of them. A print that cannot be identified is now left untracked, which is the honest answer -- the previous behaviour was to charge the operator's spools for a model they had not printed. Checked against every row rather than argued from the code. Across 273 library stems the result sets are identical. Across 241 archive stems 14 differ, all of them strictly narrower, and every dropped match is one of the false positives above; all 233 archives still match their own filename, so no legitimate donor was lost. Of the eight no-3MF archives on that install the old matcher picked a wrong donor for two -- one of them a calibration run that would have been charged the 207 g -- and the new one picks none. The second defect is that those archives could not receive a timelapse at all. attach_timelapse derived its destination from the missing file's path, and (base_dir / "").parent is the parent of base_dir, one level outside the data directory. In Docker that is /app, so every attempt failed EACCES and the scan retried and discarded the video 25 times over twelve minutes, roughly a hundred FTPS connections for bytes that had already downloaded successfully. Where that location happened to be writable it was worse: the file landed beside the installation and the attach then failed anyway, because the path could not be made relative to base_dir. #1820 introduced a shared helper precisely so these derivations could not drift apart, and this was the one site still doing it by hand. The directory is created only after the filename has passed the traversal check, so a rejected name still leaves nothing behind. The third is silence. When a print's filament cannot be read from a 3MF, the remaining-percentage delta is the fallback, and that needs a reading at print start -- which a spool without RFID does not have until someone sets a remaining amount by hand. Those slots were skipped with a bare continue. Every other reason for skipping a slot in that loop is logged, and the comment a few lines below argues the case explicitly: charging nothing silently is indistinguishable from having nothing to charge. It now says so, for slots the print actually used. Four existing tests needed updating rather than the production path. They patch backend.app.services.archive.settings by name, and the shared helper reads its own module-level binding, so they kept the real data directory and wrote outside tmp_path -- which is how the first draft of this change littered a working tree. They now patch both bindings. |
||
|
|
7a42e0a7e5 |
Show which Filament Track Switch inlet each AMS feeds
With a switch fitted, an AMS is not wired to a nozzle any more. It is plumbed into one of the switch's two inlets and reaches both nozzles through it, so every unit reports its extruder as "not fixed" (0xE) and ams_extruder_map comes back empty on these machines. The printer card had nothing to fall back on but the AMS unit number, so AMS-A was badged R and AMS-B was badged L purely because their unit ids are 0 and 1, a third unit got no badge at all, and every one of those labels was wrong. The SpoolBuddy assign modal had the same fallback in a worse form, mapping anything that was not extruder 1 to R. The binding turned out to need no new telemetry. BambuStudio reads it out of bits 24-27 of the same AMS info string we already parse for the AMS type and the extruder id -- 0 is In-B, 1 is In-A -- and it is only meaningful when a switch is installed, because without one 0xE really does mean an uninitialised unit and those bits carry nothing. That gates the read, which in turn forced the switch block to be parsed before the AMS block: _handle_ams_data runs early in _process_message and _update_state only much later, so the binding was lost on every frame that carried both. _parse_fila_switch is split out and called first, and left in _update_state as well so that stays a complete absorb step. The badge keeps L and R rather than A and B, because the lettering is familiar and matches the physical layout. It is a different colour from the plain nozzle badge, and its tooltip names the inlet in full, since the letter is the inlet's position and not a claim about which nozzle that AMS feeds -- the switch can route either inlet to either outlet. An AMS still reporting a real extruder id keeps its ordinary badge, which BambuStudio also treats as authoritative over any switch binding, and a switch that has been fitted but not yet set up on the printer shows nothing rather than a guess. The print dialog's slot dropdown gets the same label. It replaces a left/right hint that never once rendered: ftsExtruderForSlot compared snow-encoded in[] values against global tray ids and could not match. Decoding it correctly would not have saved it -- the firmware reports which slot sits in each inlet and which nozzle each outlet feeds, but never which inlet is currently paired with which outlet, so no per-slot nozzle can be derived. That function is gone rather than fixed. The dialog also points out when every filament a print needs sits behind one inlet. Bambu's own guidance is that this is legal but slow: a change between two filaments on the same inlet retracts the outgoing spool all the way back to its AMS before the next can be fed up the shared tube, where a change across the two inlets only retracts as far as the switch. All on one inlet means every change in the job takes the slow path, and moving a single spool fixes it. So it advises, it does not block. Both views update live. Two things were stopping that. fila_switch and ams_switch_inlet were absent from printer_state_to_dict, and the frontend shallow-merges each WebSocket push over its cached status, so a field the push omits keeps whatever the last full fetch left behind. And the broadcast dedup key had no term for either, so "Join IN-B" on the printer screen moved nothing: the binding is not in the tray component of that key, and it is not in the AMS change-hash either, which covers tray fields only and must stay that way because it drives Spoolman sync. Assigning an AMS to an inlet remains printer-side. BambuStudio can read the binding and has no command to write it -- its switch class is parse and getters only, and the recommended-arrangement popup draws and publishes nothing -- so there is no wire format for us to copy. Adding the two fields to PrinterState broke four test modules whose SimpleNamespace stubs predate them. The stubs are fixed rather than the production reads made defensive: the real dataclass always carries both, and a getattr in the dedup key would silently stop tracking the field if it were ever renamed. |
||
|
|
907de4d64d |
Suppress two Bandit false positives in the new FTP and batch-order tests
The 1.2.5.3 code-scanning run flagged two new alerts, both in test files added this release, and both false positives. B402, the ftplib import in the #2780 connect-cleanup tests, is the HIGH finding that failed the check. The test imports ftplib to construct the exceptions BambuFTPClient.connect has to survive -- error_perm and error_temp, at lines 50, 51 and 75. Nothing in the file opens a connection, and bambu_ftp.py already carries the same marker on its own import. B108, the /tmp path in the batch-order archive fixture, is the MEDIUM one. The value is a string written into PrintArchive.file_path so the row has a path; nothing ever opens it. Every other archive fixture in the suite carries the same marker on the same idiom. Both markers follow the wording already in test_bambu_ftp.py and test_sjf_scheduling.py. Bandit's medium+ count over backend/ drops from 17 to 15, and neither file contributes to what is left. |
||
|
|
0623cc46df |
Repair no-3MF archives' photos and their silent filament writes (#1820)
Two faults behind the same kind of print: one that arrives without a
retrievable 3MF, which on an H2S is any job started from the printer's
own internal library.
Such an archive has no file_path, and Path("").parent is Path("."), so
every site that derived the archive's folder from it landed on the data
directory itself. The finish-photo capture spotted that and wrote to
<archive_dir>/<id>/photos instead. Nothing else did. The photo was
written in one place and looked for in another: reads 404'd, deletes
dropped the name and left the file, and the notification attachment
never found the image. Hand-uploaded photos worked only because upload
and read agreed with each other rather than with the capture. Give the
question one owner in utils/archive_paths and have all four sites ask
it. Lookups check the old shared location too, so photos already
uploaded there stay reachable; uploads now go where captures go.
Separately, the remain%-delta fallback that stands in for a missing 3MF
can charge nothing for several reasons, and did so without a word. The
AMS reading is coarse and, on the reporter's printer, noisy: it rises
mid-print, swings five points over a job, sits at 100% through a
36-minute print on a fresh spool, and goes negative on a nearly empty
one -- which the start-of-print gate rejects, dropping the only slot
that was printing. Two of their prints went uncounted for two different
reasons and both read as "no spools updated", which is also what a print
with nothing to charge prints. Name the slot and the two readings in
each case, on the Spoolman path and on the internal-inventory path,
which has carried the same gates since #1119.
The Spoolman path also had no notion of which slots the print used, so a
spool swapped into an idle slot mid-print reads as consumption and is
billed to whoever that slot is assigned to -- the fault #1269 fixed for
the internal tracker, still open here, and likeliest on exactly the
prints this fallback serves, where nothing else narrows the field. Use
the same three pieces of evidence it does: the print's mapping, its
mid-print tray changes, and the tray it started on. The last needs
storing, because the internal tracker's row is deleted before this runs
and a screen-started print has no mapping to fall back on -- hence a new
nullable column, and no backfill, since a row from before it existed has
nothing to say. Where no evidence exists at all, every slot is still
considered.
Both paths also treated tray_now == 255 as naming a slot. It does not:
it is the field's initial value, the fallback for an unparseable
reading, and what it reports with nothing loaded. Mapped as a tray id it
becomes (255, 1), so as the only evidence it excluded every real slot
and charged nothing at all -- this issue's own bug, arriving by a new
route. On the internal path that is live today; on the Spoolman path it
would have shipped with the guard above. The external holder reports 254
when it is genuinely in use.
The arithmetic is untouched: at one percent per step this cannot resolve
a small print, and pretending otherwise would be worse than saying so.
|
||
|
|
fffa68ec55 |
Explain a print that never reached the printer's card, instead of sweeping for it (#2780)
Bambuddy reads a print's 3MF, cover and timelapse over FTPS on port 990, which on every Bambu model serves external storage only. Under some configurations H2-series and P2S firmware keeps the sliced file on internal storage, where Bambu Studio put it over the port-6000 service, and then no path on 990 can find it. The print command has always said which of the two it used -- `url` reads ftp://<name> or brtc://emmc/<name>. We discarded it and swept anyway: ~110 connections per print, all certain to fail, ending in an archive card with nothing on it and no stated reason. In the reporter's bundle all 35 dispatches to their H2C and P2S said internal storage, all 25 to their X1C said external, and all 44 empty cards belonged to the first two. Read the field, skip the sweep when it cannot succeed, and record which reason applied. A printer that uses the card is unaffected, and so is one we have no answer for -- silence is not evidence, and reading it as bad news would break archives that work today. The answer is held per print and dropped when that print ends, rather than kept as a standing fact about the printer. Plenty of prints never announce themselves: 14 of the 79 print starts in that bundle arrived with nothing on the request topic, started from the printer's own screen or picked up after a restart. Left standing, one slicer print to internal storage would suppress the lookup for every screen-started print after it, on a printer whose files really are on the card. The sticky reading is kept for the connection diagnostic alone, which is run after the print that prompted it and would otherwise have nothing to report. Two things that pointed the wrong way go with it. The archives banner told everyone to enable "Store sent files on external storage"; the reporter had it on for the whole three weeks and it would not have helped. The diagnostic passed a printer whose slot was empty, because it read only the toggle -- an empty slot is now a failure naming the slot, and a printer that has storage and still used its own is a warning. On P1-series that empty-slot failure yields to the existing unsupported-model skip: the toggle cannot be switched on there at all, so telling the operator to insert a card would promise a fix inserting a card does not deliver (#2524). Also close FTP sockets on the failure paths, which dropped them for the garbage collector -- 1813 in a day in that bundle -- and drop the advice to restart the printer, which the reporter tried twice while a single manual connection to the same printer handshook cleanly. This does not make the affected prints archive in full; that needs the port-6000 protocol tracked in #2762. |
||
|
|
454457a0af |
Attribute filament correctly when AMS backup swaps spools mid-print
Everything the completion path needs to split a print's filament across the trays it fed from lived only in memory: the dispatched plate and slot-to-tray mapping, the spool-assignment snapshot, and the tray-change log. A print that outlived a restart lost all of it and fell back to what the printer reports at completion -- which, with AMS Filament Backup on, is the substitute tray. The whole print was charged to the spool that only finished it while the spool that ran dry was charged nothing. Persist that context in a new active_print_sessions row, append tray changes as they happen, and restore both the session and the printer's tray-change log at restart recovery. Seed the log from the current tray when there is nothing to restore, since last_loaded_tray advances even when no change is logged. Rank the queue item's stored ams_mapping above the printer's live mapping field, which is what backup rewrites. Recover plate_id from the archive or queue item, and give extract_layer_filament_usage_from_3mf a plate_id instead of taking the first .gcode member -- a Bambu Studio export stores plate 2 first, so per-layer figures were measured against the wrong plate for both inventory backends. Stop auto-unlinking a spool assignment when its slot reports empty during a running print. At a runout the spool is still in the AMS, and dropping the link leaves the completion path nothing to charge. Capture the print-start context for both inventory backends. Spoolman's own durable row (#1820) carries its plate-scoped figures and dispatched mapping but not the tray-change log, and its slot assignments -- the way. Registration in _active_sessions stays gated, since on_ams_change reads it to decide whether to skip the remain%-based weight sync (#880). |
||
|
|
df5aa04df1 |
Pool AMS backup spools in the print dialog's filament check
The dialog weighed each plate against the spool in the slot it mapped to and knew nothing about AMS Filament Backup, so a two-plate job needing 1441 g of ABS was refused against a 1000 g spool while the identical full spool in the next slot went uncounted. The dispatcher has pooled matching spools since #1762 and would have run the print -- "Print anyway" was always the right answer to this warning. The rule for which spools back each other up now lives in one place: build_slot_materials() in filament_deficit, which the dispatcher's pool and the new slot_materials half of GET /printers/{id}/inventory-remain both draw on. The dialog groups on the keys it is handed rather than resolving spools a second time, which is what let the two answers drift apart, and which also gives the check to Spoolman users -- it read the internal inventory only, so in Spoolman mode it approved everything. Where a pool really is short the warning quotes the pooled totals, since the per-slot figure reads as a contradiction next to a full peer spool. |
||
|
|
15ea11e10b |
Recover the preview slice from custom G-code the sidecar cannot parse
Opening the slice dialog on an unsliced project runs a preview slice purely
to ask the slicer which AMS slots the chosen plate consumes. Bambu Studio 2.8
writes {if timelapse_inline_photo} into the machine's time_lapse_gcode but
exports no definition for that variable, so the template is unresolvable the
moment it leaves Studio: an older sidecar stops with a placeholder parse error
before producing any slice_info. The preview returned nothing and the caller
fell back to guessing from painted faces, silently. On the H2D project this
was found with, the guess dropped the support material -- a whole slot off a
four-filament plate.
Retry the preview once with just the named template emptied, still on the
file's own settings. Keeping the embedded settings is what keeps the answer
honest: overriding the process preset instead discards the project's support
configuration, which loses that slot and moves used_g by up to 2x. Measured
against the same file: retry reproduces all four slots gram for gram, a
printer+process override returns three.
Only templates that cannot extrude are eligible -- a start or filament-change
template lays a prime line or purges, so emptying one would move the very
grams the preview reports, and returning nothing beats a confident wrong
number. Verified on a working H2D slice that emptying time_lapse_gcode leaves
every used_g/used_m in slice_info identical.
Match on a normalised option name: the slicer reports timelapse_gcode while
the 3MF stores time_lapse_gcode, so a literal comparison finds nothing.
Decide whether a retry applies before logging, so a slice that recovers does
not announce itself at WARNING twenty seconds before it succeeds.
|
||
|
|
ec26cba927 |
Resolve the H2C rack nozzle at dispatch instead of letting firmware pick (#2800)
An H2C ran its startup clean and bed levelling on one hotend, switched, and then printed several millimetres above the plate. The same job from Bambu Studio was fine. The H2C is the only model that mounts its nozzle from a rack of six, and a print command names that nozzle by physical rack position -- the firmware reports those as 16 to 21 -- not by the extruder index, 0 or 1, every other dual-nozzle printer uses. Bambuddy only ever had a rack position when a job arrived through the Virtual Printer, which captures Bambu Studio's pick and replays it (#1780). Anything queued from the library, an archive, the webhook or a slicer pipeline carried none, so the field was omitted and the firmware chose -- and its choice need not match what the file was sliced for. The scheduler now derives the per-slot extruder assignment from the file it is about to send, and the MQTT layer resolves it against the rack position the printer reports live. Both are needed: the file knows which side a slot prints from, only the printer knows which hotend is in the carriage, and it can be swapped from the touchscreen between queueing a job and printing it. Derived at dispatch rather than at creation because that is the first point knowing both the real printer and the real file -- an item can be created unassigned, reassigned later, or have its file swapped for a G-code-injected copy. One call therefore covers the print dialog, bulk library adds, the webhook and pipeline runs, and no column is needed. extract_nozzle_mapping_from_3mf is deliberately untouched. Its output feeds the AMS matcher, where nozzle_id is compared against a tray's extruder_id as a hard filter, and physical_extruder_map is what makes that comparison correct -- on an H2D it is [1, 0] and flips the two. Dropping the translation to suit the rack would send every dual-nozzle AMS match to the wrong extruder. The dense per-slot form is a separate function reusing the same output. Nothing here can fail a dispatch. The command is built and published with no exception handler above it, and the queue item is already committed as printing by then, so a bad input has to degrade to "firmware picks" rather than wedge the item. resolve_rack_nozzle_mapping validates every input and raises nothing; an unresolvable mapping, an unparseable value or an unknown rack position all omit the field, which is the behaviour that existed before. Slot IDs are bounded before the dense list is built: they come from the file, and one declaring filament id="50000000" would otherwise allocate a fifty-million-entry list on the dispatch path. Two things are not guessed. A job printing only from the fixed hotend is still left to the firmware, because that nozzle's physical ID is not confirmed by a known-good capture. And the rack is taken to feed extruder 0 from a single hardware observation -- if that is flipped, a one-sided job matches nothing and falls back to the old behaviour, so only a job using both nozzles at once could be harmed, which is what a second capture needs to confirm. Confined to the H2C throughout. Building the print command for 21 model spellings with and without the new argument changes exactly three of them -- H2C, O1C and O1C2. The other 18, including H2D and X2D, are identical. Reported by @tru3l3gend, who diagnosed it on real hardware against a working Bambu Studio dispatch, established the rack ID range and supplied a patch. |
||
|
|
328bac450a |
Stop auto-drying re-arming into a threshold it can never reach (#2770)
An H2D armed five 12-hour drying cycles inside four hours, one of them six seconds after the previous one ended, and none ran more than a couple of hours. Two things combine. The firmware ends a cycle when it decides the filament is dry rather than when the clock runs out, and reports no fault doing it -- across this printer's history the run length tracks how wet the spools were, from nearly the full 12 hours starting at 32% down to minutes once the unit sat at 10-13%. That part is the AMS doing its job. The loop is ours. An AMS reports higher relative humidity while it is warm than once it has cooled: the same unit read 10-13% cold and 15-20% through every cycle. With the threshold at 14% the reading at the moment a cycle ended was always still above it, so the next 30-second pass armed another 12-hour cycle. Nothing counted, nothing waited, and it only stopped when the box finally cooled enough to read 13%. Auto-drying now waits 30 minutes after a cycle ends before arming another on the same unit, and gives up on a unit after two consecutive cycles that bring the reading no lower -- logging why and sending a new notification, on by default because it reports that Bambuddy has stopped acting. Progress is judged against the lowest reading any cycle on that unit has ended at, not against the threshold, so a genuinely wet spool in a humid room coming down 40-37-35 keeps drying however far it still is from the target; comparing against the best so far rather than the previous end stops a sensor wobbling by one point reading as progress every other cycle. The suspension lifts by itself once the reading falls below the threshold. Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a print, or that the user stopped by hand, is not counted against the unit -- so a farm that dries between queue jobs is unaffected. The threshold field now warns below 20%, and every cycle end logs the unit's temperature and humidity, which is what made this diagnosable. The same bundle showed unrelated tasks failing with "database is locked", each inside a 30.000-second Discord connect timeout. Alarms are raised from inside the loop that records sensor history, at a point where the new rows are added but not committed; the first read in the notification path flushed them to satisfy itself, opening a write transaction, and the provider was then contacted over the network with that transaction still open. SQLite allows one writer and 30 seconds outlives the 15-second busy timeout, so every other write in that window failed. The two reads that run before a provider is contacted no longer flush the caller's pending work, and the connect timeout is 5 seconds rather than 30 -- the body keeps the full 30, so image uploads on a slow uplink are unaffected. SQLite only; Postgres has no single-writer limit. |
||
|
|
406cf71149 | Merge branch 'dev' into feature/billing | ||
|
|
91acac2b35 |
Stop retrying a printer whose FTPS handshake fails, and name the cause (#2780)
Two printers went on printing while every archive they produced held nothing but a filename. Bambuddy opened port 990, the printer accepted the connection and answered with something that was not TLS, and connect() logged a warning and returned False -- indistinguishable, to every caller, from "the file is not at this path". So the 3MF lookup walked all six filename variants across five directories with four retries each, the cover endpoint ran its own sixteen-path sweep, and the timelapse scan added four more, all against a sixteen-path sweep, and the timelapse scan added four more, all against a printer that could not have answered any of them. One reporter's log carried 1813 identical handshake failures, another's 3511. The evidence says this is the printer's own file service getting stuck, not a model, firmware or TLS-configuration problem. In #2780's bundle the same two printers ran clean from 22 July to 4 August and failed again from the 5th; a second bundle shows an X2D serving files for five days, flipping on 19 July, then failing every connection for eight days with zero successes. The same models and firmware appear in roughly twenty other bundles with no occurrences at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly this symptom, and the reporter's own debug line proves they do not. An ssl.SSLError from connect() now opens a five-minute cool-off for that printer. Subsequent connects return False without touching the network, so a wedged printer is contacted twice an hour instead of hundreds of times a minute, and the single warning that is logged names the remedy. The cool-off is dropped on expiry rather than kept, so the map holds one key per currently wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup abandons the remaining paths and skips the directory-walk fallback, the cover endpoint returns 503 naming the file service instead of a 404 that reads as "this print has no thumbnail", and the timelapse scan separates 503 (cannot reach the printer) from 404 (no timelapse directory) -- one 500 used to cover both, which is what the reporter hit when reproducing. The Connection Diagnostic completed a bare TCP connect to 990, which is why it reported the port green throughout: the port is open, it is what is behind it that is broken. It now completes a real implicit-TLS handshake using the model's own ftp_profiles cap, so a pass means the FTP client would also get through. An open port that cannot negotiate reports warn with reason no_tls, selecting a new message in all 13 locales that points at a printer restart rather than at the firewall. No login is attempted, so this stays valid in the pre-save Add Printer flow. The cool-off tests run against a real socket that accepts on 990 and replies with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off map too -- every test here talks to 127.0.0.1, so one left behind would make the next test's connect() a no-op. |
||
|
|
a9e23910fc | Merge branch 'dev' into feature/billing | ||
|
|
dd1d40b0d4 |
implemented pr (worth fixing) feedback
update commit |
||
|
|
9c86a05657 |
Check filament deficit for Library-backed queue items (#2779)
A job needing 20.5 g was dispatched onto a spool holding 9 g and the printer started. _resolve_source_3mf returned LibraryFile.file_path verbatim, but that column stores a path relative to base_dir -- so it resolved against the process working directory, found nothing, and compute_deficit_for_queue_item treated a missing source as "nothing to verify" and returned no deficit. Every library-backed queue item was affected: Slicer Pipeline jobs, which are always library-backed, and everything added through the Library's bulk Add to queue. Both callers share the resolver, so the Play button on the queue was as blind as the auto-dispatcher. Archive-backed items (print history, VP intake) resolved correctly and were never affected, and neither was PrintModal, which resolves the file on its own path. The library branch now uses the same idiom as the eleven other readers of file_path -- absolute stays, relative joins base_dir. The join carries a SEC-PATH-OK marker: the value is DB-stored and generated by the Library ingest, and it is already what resolves the file for upload, so the check has to resolve it identically or it is not checking what gets printed. A source that is configured but absent now logs a warning naming the item and the resolved path. It still dispatches, because the upload needs the same file seconds later and fails there, where blocking would strand a queue on a moved file -- but a safety check that skips itself must not do so in silence, which is what hid this for every library-backed item. Tests cover the relative path (the reporter's 20.5 g against 9 g), the absolute path against a base_dir the file is not under, and the missing-source warning. The existing cases all used archives with absolute paths, which is the gap the bug lived in. |
||
|
|
1eea194953 |
Resolve a spool's material to a known drying preset before starting a cycle (#2774)
The drying popover prefilled its material from the loaded spool without checking the preset table had that material. An AMS-HT holding Support for PLA/PETG (tray_type PLA-S) fell back to PLA's temperature but kept PLA-S as the material, and the dropdown displays its first option when handed a value outside its list -- so it read PLA while PLA-S was sent. Same gap for every composite: PETG-CF prefilled at PLA's 45C. Resolve the tray_type to a key the table has before setting either value. Support materials and composites resolve to their base, nylon is aliased under its several spellings, and anything unrecognised falls back to PLA -- the coolest row, so an unknown material under-dries rather than deforming a PLA spool. Also record request-topic messages in the MQTT debug log. That topic carries every command a printer is given, including Bambu Studio's, and returned before the logging block -- so a capture could show only what the printer said, never what it was told. |
||
|
|
9a397f46e9 | implemented pr feedback #2 | ||
|
|
43bf854bd5 | Merge remote-tracking branch 'upstream/dev' into feature/billing | ||
|
|
0596ff424e |
Say why a drying cycle ended when the firmware cuts it short (#2770)
An H2D started a twelve-hour PETG dry at 65 degC and the AMS gave up on it twenty minutes in, with 700 of the 720 minutes still on the clock. It cooled, humidity climbed back over the threshold, auto-drying started another twelve-hour cycle, and that one went the same way; the reporter's AMS temperature history shows the loop running all morning. The log had one line for it: "AMS 0 drying complete", which is exactly what it says for a dry that ran its full twelve hours. Nothing in a support bundle told the two apart, and the one number that does -- the time still remaining -- was written into that line as the previous value, where it reads like a duration rather than a shortfall. The reporter took 700 for seconds and concluded the cycle had lasted twelve minutes. Bambuddy did not stop that cycle; every stop it sends is logged with the full outgoing command and there was none. So ending it was the printer's decision, and the account of why lives in three things already received and parsed and never written down: the drying phase and sub-phase from the AMS info hex, the per-unit dry_sf_reason constraint codes, and the live HMS errors. A cycle that ends with most of its countdown left now logs all three alongside how much of the requested duration ran. One that reaches its duration keeps the single line it has always had. A stop Bambuddy sent is named as ours -- it is short of its duration too, and on the telemetry alone is indistinguishable from the firmware abandoning the cycle, so without tracking it the print-takes-priority stop and the Stop button would both have been blamed on the printer. Diagnostics only. Nothing about when drying starts or stops has changed, and the restart loop is not addressed: what the firmware objects to has to be established before Bambuddy can sensibly decide how long to wait before trying again. |
||
|
|
a74dc7932f |
Cover nested data structures in the HA notify pass-through (#1441)
The three tests around it use flat scalars, which is also all the field's placeholder and the wiki showed, so nothing recorded that the value is forwarded verbatim rather than treated as a key/value list. A reporter asked whether action buttons work; they always have, and now that is pinned. The changelog entry said "nested options work" and left it there. It now names actions and the two things that decide whether the buttons do anything - the mobile_app_notification_action automation, and iOS needing a registered category - since neither is set from Bambuddy and both are what a reader would otherwise have to discover the way the reporter did. |
||
|
|
5efbd353ed |
Survive a directory that defines no posixGroup class (#2769)
Every LDAP user on an lldap directory was rejected with "Incorrect username or password", on an install where Test Connection passed and where the same bind DN, filter and group membership all checked out under ldapsearch. The directory never saw the request. _extract_user_info searches for POSIX groups alongside the memberOf ones, and both of those filters name the posixGroup object class. ldap3 fetches the schema at connect time (get_info=ALL) and validates class names in a filter against it while building the request, raising LDAPObjectClassError before anything is sent. lldap marks every account it creates as posixAccount -- which is what makes us look for POSIX groups at all -- but defines no group class beyond groupOfNames. The exception escaped authenticate_ldap_user, and the login route reports any LDAP failure as bad credentials. A directory with no posixGroup class has no posixGroup entries, which is exactly the answer those searches would have returned. Catch it, log it once, and carry on with the memberOf groups collected above. Both searches sit inside the one try: they name the same class, so once one is rejected the other cannot succeed, and attempting it would only produce a second identical exception to swallow. Not a regression from |
||
|
|
6fb6b845e7 |
Read the printer's own slot mapping when Spoolman has none (#2768)
A sliced file numbers its filaments 1..4; which AMS tray each came from is a separate decision made when the job is sent. store_print_data learns it from one of two sources, both of which require the print command to pass through us: the mapping Bambuddy chose itself, or the one it intercepted on the printer's local request topic. A job dispatched from Bambu Studio while the printer is cloud-bound satisfies neither -- the command travels through Bambu's broker and never reaches the topic we subscribe to. slot_to_tray is then NULL and _resolve_global_tray_id guesses by position: filament 1 from the first loaded tray, filament 2 from the second. The reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so all four slots were charged to the wrong spool. Their log carries the printer's own answer, mapping=[1, 3, 0, 32768], sitting unread. usage_tracker has consulted that field since it started resolving mappings at completion, along with a colour match against the loaded trays for the models that never publish it (A1, A1 Mini, P1S, P2S). Only the Spoolman writer, which resolves at print start, never learned to -- and main.py gates usage_tracker behind Spoolman being off, so enabling Spoolman is what costs you the better resolver. _resolve_slot_to_tray_fallback gives it both, at completion rather than at print start: a printer keeps publishing the last job's mapping while it sits idle, so reading it early would risk stamping the previous print's mapping onto this one. A mapping we or the slicer actually recorded is never second-guessed. Applied in _report_partial_usage too. Cancelled and failed prints feed the same slot_to_tray to the same resolver and mis-charged just as readily. The resolved mapping and its source are now logged at print start and at completion. "source: none" at start is the signal that completion will have to fall back, and it was the one line that would have turned this report into a five-minute triage. Not addressed: editing the mapping after the fact, which the reporter also asked for. ArchiveUpdate exposes neither filament field and there is no way to re-run an attribution, so that is a feature rather than a fix. |
||
|
|
fce7ea0200 |
Stop the drying badge inventing a temperature on a uniform AMS (#2759)
The follow-up to the same report: a second AMS 2 Pro, no aux power, loaded entirely with PLA and drying at the 45C the reporter picked, showed 45C and then switched to 55C. Bambu never echoes back a cycle's filament or temperature, so both come from the target cached when the command went out, and the fallback for a missing cache reads the loaded trays. The first pass narrowed that fallback to units whose spools agree on a filament, which fixed the mixed-unit case in the original report but left the uniform case answering with the spools' RFID-recommended drying_temp -- 55C here. Agreement across slots is evidence of what is being dried, because the dryer heats all of them. It is no evidence of the temperature, which is picked freely in the popover, so the recommendation was never more than a guess wearing the same confident "PLA @ 55C" as a known target. uniform_tray_drying_hint therefore becomes uniform_tray_filament_hint and returns the filament alone. The badge names a temperature only when we sent it, and otherwise shows the filament and the countdown. Both status builders also stopped filling the two fields independently. Entering the fallback when either was missing let a cached filament pair with a guessed temperature and render as though both were known; the temperature now simply has no fallback to reach. The badge required both fields before rendering anything, so dropping the temperature would have blanked it rather than shortening it -- the frontend now renders each on its own terms. No new translation key: the filament type is a passthrough. This changes what is shown when the cached target is missing, not why it goes missing. If the reporter was on the fixed build, the falling- edge gate is still letting a zero through on an unpowered unit, which needs a log covering the start of the cycle. |
||
|
|
33ab5f1ead |
Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.
Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.
The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.
Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.
Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.
The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
|
||
|
|
48c231d8ce |
Stop a drying cycle reporting itself finished a minute in (#2759)
Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools and picking PLA showed "PLA @ 45°C" for about a minute and then switched to "PETG @ 65°C" for the remaining twelve hours. Bambu never echoes back which filament or temperature a cycle is running, so the badge reads the target cached when the command went out, and that cache had been dropped. Between accepting the command and settling its countdown the firmware publishes one update with the remaining time at zero while the unit is still in its Checking phase -- the reporter's log has 720, then 0, then 719, and four seconds later the same unit's info hex decodes to dry_status 2, Drying. The falling-edge detector read that zero as the cycle ending. Losing the cached target left the badge to guess from the first loaded slot, which was PETG, and its RFID-recommended 65°C. The same false ending fired on_drying_complete, so anyone with smart-plug auto-off-after-drying switched on had power scheduled to cut one minute into a twelve-hour dry; the reporter had it off, which is the only reason this reads as a cosmetic bug. A remaining time of zero now ends a cycle only when the unit is not also reporting an active phase. dry_status comes from the same info hex already parsed a few lines above, so this costs nothing to check. Stopping and Error are deliberately not treated as active -- those should end it -- and a unit that reports no phase at all still ends its cycles, so the gate can only ever suppress on positive evidence that the cycle is live. A suppressed edge leaves the remembered dry_time alone, exactly as the #1462 absent-value skip does, so the push that really ends the cycle still sees a non-zero previous. The fallback guess is tightened to match. On a mixed unit the first tray is evidence of nothing, and naming a temperature the cycle is not using is worse than naming none, so it now answers only when every loaded spool is the same filament and otherwise leaves the badge showing the countdown alone. Both the websocket and REST status builders carried their own copy of that loop; they now share one helper, which also takes the temperature from the first slot that carries an RFID one rather than giving up when slot 1 holds a third-party spool. |
||
|
|
28a6ca6f4d |
Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
The Virtual Printer binds 990 and 322, below 1024, which a service running
as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
rest of Bambuddy works and only the VP is dead -- sockets never open, the
slicer never finds the printer, and the sole trace is one journal line.
|
||
|
|
4af782cc2f |
Report a refused AMS filament setting instead of discarding it (#2756)
Configuring a slot publishes ams_filament_setting and the printer answers with a verdict. The answer was received and dropped at DEBUG, so a refusal left no trace at the level support bundles are collected at: the reporter saw six Configure Slot attempts on an X1C all return success, all read back by the #2582 verification as holding the previous profile, and no record of what the printer said about any of them. Promote a non-success response to INFO with result, reason, ams_id and tray_id. Refusals only -- unlike extrusion_cali_set (#2718) and ams_filament_drying (#1447) this command is not rare, since every spool assignment and K-profile re-apply sends one, so promoting each ack would bury the interesting line. The developer-mode probe is excluded: it sends this same command to the external slot expecting a refusal on P1 firmware, so promoting it would put an alarming line in every P1 bundle on every reconnect. Matched by sequence id, which user commands cannot collide with -- they publish a hardcoded "0". Diagnostics only; no change to which commands are sent or how they are built. |
||
|
|
e95c42c021 |
Add auto-orient and auto-arrange to server-side slicing (#2548)
Both are per-slice checkboxes, off by default, forwarded as the sidecar's orient / arrange form fields. An unticked box is sent by omission: the sidecar treats any present value as truthy, so a literal "false" would have arranged every slice. Arrange unions with the #1493 cross-class decision rather than replacing it, and the per-plate slice-all loop is now keyed on the arrange flag itself — the project-wide collapse belongs to --arrange, not to the cross-class case. The loop also covers the embedded-settings path, whose crash-retry is suppressed there since a single --slice 0 retry would return one consolidated plate. |
||
|
|
e52b73e21f |
Stop the Bambu Cloud TOTP tests reaching the network
verify_totp fetches a CSRF token from the bambulab.com web origin before posting the code (#2696) and returns early when it cannot get one. These tests patch only post, so the pre-flight GET went out for real: it succeeded wherever bambulab.com was reachable and returned a tokenless 403 on a CI runner, where six tests then asserted on a post that never happened. Stub the handshake for the module. It is covered end to end, no-token path included, in tests/unit/test_cloud_totp_csrf.py. |
||
|
|
18938a10ee |
fix(kprofiles): stop reporting rejected K-profile writes as saved
Saving a K-profile was fire-and-forget. set_kprofiles_batch published and returned True, and the printer's extrusion_cali_set answer was logged at DEBUG and dropped, so a write the printer refused was reported to the user as saved (#2718, reporter @jmoore-skild). The reason it could not simply be gated on: the answer itself was wrong. Single-nozzle firmware returned result:"fail" with reason:"invalid tray_id" on writes that demonstrably applied. Measured against an X1C and an H2D over MQTT, the cause is the tray_id:-1 Bambuddy itself put in the payload. Sending three otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0 succeeds, and cali_idx:-1 is accepted either way, so only that one field is at fault. The H2D ignores the value entirely; the X1C validates it, complains, and applies the write anyway. BambuStudio always sends a real tray_id and defaults it to 0 for a manually entered profile. With tray_id:0 the acknowledgement is honest, and the printer echoes back the sequence_id we sent -- confirmed for extrusion_cali_get, _set and _del on both printer classes -- so it can be matched to the write that caused it. Writes now return their sequence_id and the routes await the verdict, turning a real failure into an error that carries the printer's own reason. A printer that stays silent is still treated as success: no answer is not evidence of refusal, and firmware that never answers must not turn every save into an error. Raises the ack to INFO. It sat at DEBUG, so the one line that explains a failed save was absent from every support bundle -- the same reasoning that put ams_filament_drying at INFO for #1447. Also fixes extrusion_cali_set building its payload from str(self._sequence_id) without incrementing first, reusing the previous command's id. Harmless while nothing correlated on it, fatal now that the write path does. Adds supports_nozzle_flow_type() for the Standard / High Flow choice, which the K-Profiles UI previously showed as "Not reported by printer" -- not a value anyone can save. Most printers omit the nozzle identity from their calibration table entirely, and the slicer treats that as Standard rather than unknown; Bambuddy now does the same and keeps the choice editable. The field is hidden only where the model ships a single nozzle variant, using the slicer's own rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine preset) evaluated across every bundled Bambu profile. That puts only A1, A1 Mini and A2L on the hidden side -- it is not the single- versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S are all single-nozzle and all carry two variants. Editing a profile also no longer writes back an empty nozzle_id. Wiki records that on printers which omit the field the chosen flow type is discarded by the firmware and reads back as Standard, in Bambu Studio as well, so it does not get filed as a bug again. |
||
|
|
a35ba8fa5f |
fix(kprofiles): read the nozzle diameter the printer actually sent (issue #1748)
Every K-profile came back as 0.4mm on printers running any other nozzle (#1748, reporters @Liquidmasl and @jmoore-skild). The printer puts nozzle_diameter on the extrusion_cali_get envelope only; the per-filament entries carry setting_id, filament_id, name, k_value, n_coef and cali_idx, and nothing else. The parser read the field per entry with a hardcoded "0.4" fallback, so the fallback fired on every profile of every response. The envelope value was already in scope, read into response_nozzle and used only to match the request. This never reproduced on H2D because that firmware does include the field per entry. Both construction sites are in the same handler, so the code path is shared; what differs is the payload, and every single-nozzle model omits it. The display was the least of it. Editing is delete-and-re-add on single-nozzle printers, and the dialog rebuilt nozzle_id and nozzle_diameter from its own greyed-out selects, so saving an untouched 0.6mm profile rewrote it on the printer as HH00-0.4. Deleting aimed extrusion_cali_del at the wrong nozzle the same way. Both now pass through what the printer reported. The cali_idx cascade in inventory.py, spoolman_inventory.py and spoolman.py matches on nozzle_diameter, so on a 0.6 or 0.8 nozzle it never found the printer-side entry and the assignment silently failed to stick -- that is the "cannot auto-map a K-profile" half of the report, fixed at the source without touching those three call sites. nozzle_id has no source in the payload at all, and state.nozzles carries material (hardened_steel), not flow, so it cannot honestly produce HH/HS. Rather than keep inventing one, the UI now says the printer did not report it: the card shows the diameter alone, the dialog shows "Not reported by printer", and the High Flow / Standard filter is hidden instead of being offered as a control that can only ever empty the list. Import stops stamping HH00 on profiles whose source reported none. Also correlates K-profile requests by sequence_id. Responses were matched by nozzle diameter through a single shared expectation slot, so a second request overwrote the first's and the first's valid answer was discarded as a mismatch -- the "Failed to get K-profiles after 3 attempts" in the same logs, with the printer having answered correctly both times. Pending state is now one entry per request, keyed by the id we already send, with the nozzle match kept as a fallback for firmware that does not echo it back. Fixes the flow-type select naming a new profile with the opposite label, which contradicted the identical expression 44 lines above it. |
||
|
|
4f2c073a34 |
fix(vp): gate the slicer's AMS pick behind the toggle and scope its badges (#2700)
Round-3 review of the "Save AMS mapping" PR. The queue item's ams_mapping was set unconditionally, on the reasoning that honouring the slicer's own pick is a correctness fix rather than a feature. It is both. Storing a resolved mapping makes _ensure_ams_mapping return early, so _compute_ams_mapping_for_printer never runs — and that function is where prefer_lowest_filament lives, along with the AMS-filament-backup gate that qualifies it (#1766), the inventory-remain overrides, and the per-slot force-colour overrides. Every existing queue-mode VP pointed at a printer would have quietly lost all of it on upgrade, without a setting to turn it back on. So save_ams_mapping now gates the queue item too, not just the archive persistence. Off is exactly the old behaviour. The correctness case the PR was written for — two spools of the same red PLA, and the slot the user picked in the slicer thrown away — is still fixed, for anyone who asks for it. Force color match wins over it when both are on. Its only effect on a fixed-printer item is the filament_overrides written onto the queue item, and those are read inside the function a stored mapping skips, so the two toggles sitting next to each other on the same card silently cancelled. The dispatch now matches strictly, as asked, while the slicer's pick is still saved onto the archive — that is what the toggle's name promises, and a later reprint is a separate decision from this print. The queue-add fallback applies the same rule to a request that carries force-colour overrides. A mapping shorter than a plate's highest slot id cannot address that plate's own slots, and _ensure_ams_mapping would have kept it anyway, since it only rejects an all-unresolved one. Each plate now checks the length it needs and falls back to a computed mapping if the array does not reach. Bambu Studio sends a file-global array, so this normally never fires; it also means a multi-plate Send All degrades safely if that ever stops being true. The badges claimed more than they delivered. Both rendered whenever a saved mapping existed, ignoring which printer it belonged to, while the tooltips promised the reprint would reuse those exact spools — true only on the printer the trays were resolved against. The queue row's flag is now computed against that row's own printer, which is precisely when dispatch reuses the mapping, and the archive card names the printer instead of implying any of them will do. It hides itself when that printer no longer exists. Retranslated in all 13 locales. Frontend tests, which the PR had none of. The printer-scoping rule is now a pure function rather than an inline expression, covered for the mismatched printer, the no-printer-selected case that would otherwise compare undefined against undefined, and malformed extra_data. The toggle's undo bookkeeping is covered for unresolved slots, short mappings, and hand-made picks — preserved when the toggle never wrote that slot, replaced when it did, which is behaviour worth pinning either way. Also reverts all three queue-mode switches when a save fails, not just the new one; without it the card shows a setting the server rejected. |
||
|
|
037688514a | Merge remote-tracking branch 'upstream/dev' into feature/billing | ||
|
|
4b4cb18a64 | Merge branch 'dev' into feature/save-ams-mapping-toggle | ||
|
|
5e2b7b53e6 |
fix(printers): surface the printer's own "command verification failed"
A P1S on firmware 01.10.00.00 rejected every control command and said so: HMS 0500-0500-0001-0007, "MQTT command verification failed". Bambuddy received that, dropped it, and reported a healthy printer instead. The frontend filtered it out. This code's meaning lives in attr's low half (0500) and code's high half (0001), both of which the MMMM_EEEE short form discards, so it collapsed to "0500_0007" — no catalog entry, no firmware actions, and filterKnownHMSErrors drops uncatalogued action-less errors. Catalog lookups now try full_code first, in both the description and the filter, and errors matched that way display the four-group code the printer's own screen shows. The remedy line is ours, not Bambu's: their wiki says to update Studio or Handy, which does not apply to a print sent from Bambuddy. The developer-mode probe made it worse. It read anything that was not an explicit refusal as confirmation, and this firmware answers the probe with an empty result while refusing everything else — so an inference drawn from a non-answer became "developer_mode: pass" in the support bundle of a printer that had not accepted a command all day. The probe now has three outcomes: explicit success enables, explicit verify-failure disables, anything else stays unknown and the diagnostic reports skip. The HMS is authoritative over that inference in both directions. It forces developer_mode False when present, and clears back to unknown when the printer stops reporting it, so enabling Developer Mode and restarting the printer is picked up without restarting Bambuddy. Dispatch no longer treats a refusal as a wedge. The watchdog latches the HMS across both phases and fails the item on the first attempt naming the code and the fix, rather than spending three uploads and 270s a lap to arrive at a message about SD cards. The check runs after the active-state exit in both phases, so a lingering HMS can never abort a print that is visibly running. Also: the "wrong or mis-cased serial number" hint no longer fires in the moment after a reconnect. _report_messages_since_connect is reset by _on_connect, so a reconnect landing microseconds before the staleness check leaves it at 0 for reasons that have nothing to do with the serial — this reporter's healthy printer was told to go check its serial 1 ms after reconnecting. |
||
|
|
db6cdb0745 |
fix(camera): take the finish photo when the print ends, not when its last layer starts (#2547)
The photo fired the moment layer_num reached total_layer_num. That edge is where the printer *starts* its final layer, not where it finishes it: the reporter's H2C capture shows it arriving at 92% with mc_remaining_time=2, three minutes and seventeen seconds and one filament change before the print actually ended, so the frame caught the toolhead mid-print over the model. The trigger also latched _finish_photo_captured, which locked out both the stage-22 and FINISH triggers for the rest of the print — so on firmware that never reports an end-of-print filament unload (H2C and A1 Mini confirmed) nothing could replace the bad frame. Remove the last-layer trigger. The photo is now taken at the FINISH-state trigger, which every model sends and which lands after the toolhead parks. Since Bambu's end G-code drops the plate ~100mm just before that, restore the framing before capturing: absolute G90/G1 Z to max_z_height + 10mm clearance, settle, capture, then drop it back so the print is as reachable as the printer left it. Absolute is the safety argument — that Z is a height the toolhead occupied seconds earlier, so it is inside the travel limits by construction and leaves the nozzle above the part, and it is unambiguous across model families because Z is the nozzle-to-bed gap whether the bed moves or the toolhead does. M211 is never touched (#2579). This is what #1145, #1397 and #1565 asked for. The height is only trusted when two independent sources agree: the archive is matched by the finished print's subtask_name by equality (not LIKE, so "Cube" cannot resolve to "Cube v2"), and its layer count from the 3MF must match the layer count the printer reported over MQTT. Matching on "most recent archive for this printer" was not safe — on_print_complete pops the _active_prints binding concurrently, and a print Bambuddy failed to archive would have resolved to its predecessor. A wrong height is the one failure that could drive the nozzle into the model. The move is additionally skipped when the print height is unknown, when a queue item is pending for the printer, when the printer has left FINISH, and when the new finish_photo_restore_plate setting is off. for every FINISH-state capture — which is what shipped the mid-print photo — the bank is used only when the dispatcher recorded that it injected End G-code into this print, since a SwapMod snippet may have ejected the plate. The flag is handed over in two steps (mark_pending at dispatch, adopt at print start) so it can never outlive its print: a job started from the slicer or SD card adopts False rather than inheriting its predecessor's answer. Those prints also skip the plate move outright, bank or no bank. The bank now refreshes on mc_percent advances as well as layer changes, via a new on_print_progress callback. Layer changes stop the instant the final layer begins, which left the #1867 fallback frame stale by the whole length of that layer; progress keeps ticking there and freezes before the End G-code runs, so a swapped plate still cannot reach the bank. The last-layer throttle exemption is dropped, since it would now fire a grab on every percent tick. On the timelapse path the moment producer returns early, so the consumer does the restore itself before its live-grab fallback — the documented usual outcome on P1-series, where the video has not transferred by the time the notification goes out and the shipped photo was of an already-dropped plate. The two waits are now derived from the settle window and the video poll timeout rather than hardcoded; at the old flat 75s that fallback was guaranteed to be cut off mid-settle. extract_max_z_height_from_3mf reads only a bounded prefix of the plate G-code, since a sliced plate is routinely tens of megabytes and the header is ~40 lines. It returns None for missing, unparseable, zero and negative values so callers must treat "don't know" as such rather than defaulting. |
||
|
|
13c37ffe51 |
fix(vp): scope saved AMS mapping to the printer it was resolved against
Round-2 review fixes for #2700. Blocking: the toggle didn't actually gate the archive write. archive.py's promotion fired for any print_data carrying ams_mapping, but bambu_mqtt's request-topic interception captures ams_mapping unconditionally for every print source (slicer-direct LAN prints included). Since main.py's real-printer auto-archive path forwards the full MQTT payload as print_data, every archive on any install — VP or not — grew extra_data.slicer_ams_mapping. Fixed by replacing the print_data-sniffing with an explicit `slicer_ams_mapping` param on archive_print() that only the VP-queue path (already gated on save_ams_mapping) ever passes. Blocking: a saved mapping could get reused on a printer it was never resolved against — tray IDs only mean something relative to one printer's AMS layout. extra_data.slicer_ams_mapping is now stored as {mapping, printer_id} instead of a bare array: - add_to_queue's fallback only fires when the reprint's target printer_id matches the mapping's origin printer. - The frontend's archiveAmsMapping only surfaces (and the Mapping button only appears) when the print modal's selected printer matches too. - A model-based VP (target_printer_id=None, no MQTT bridge to any real printer) never stamps a mapping in the first place — there's no live AMS layout for the slicer to have resolved tray IDs against. Also from review: - Multi-plate archives now get the Mapping button too (the per-plate FilamentMapping loop was missing archiveAmsMapping entirely). - Added coverage for the previously-untested late-MQTT archive patch path (_restamp_recent_queue_item), including the model-based-VP skip case. - usingArchiveMapping now also resets on printer change, not just plate/archive (it already worked via the printer-scoping above, but is now an explicit dependency too). - The Mapping button's revert (OFF) now undoes only the slots it itself set, not every manual pick in scope — matches the comment above it. - Added a comment on why negative-value slots (external spool) are skipped rather than cleared when applying a saved mapping. |
||
|
|
bab1cfb906 |
feat(vp): per-VP "Save AMS mapping" toggle + reprint auto-apply
Lets a reprint reuse the AMS slot the slicer itself picked, instead of re-deriving one from the file's static type/color. When a Print Queue VP has "Save AMS mapping" on, the slicer's own live-resolved ams_mapping (from the project_file MQTT command) is persisted onto the archive as extra_data.slicer_ams_mapping. A later reprint can reuse it via a new "Mapping" button in the filament-mapping panel — one click snaps every slot to the saved pick, click again reverts to auto-match. Archive cards and queue rows get an "AMS mapping saved" badge so it's visible beforehand. add_to_queue also falls back to the saved mapping automatically when the caller sends no explicit ams_mapping (e.g. a plain reprint with no per-slot edits). The queue item's own ams_mapping (used for that dispatch) is still captured unconditionally whenever the slicer provides it — that part is a correctness fix, not gated behind the toggle. Only the archive persistence for future reprints is opt-in. Split out from the original combined PR per review: this half is genuinely opt-in and low-risk (#2684). The dispatch-time validation gate that keeps a stored mapping honest (#1308) changes behaviour for every existing user and will land as its own PR. Review fixes applied: - _extract_slicer_ams_mapping_json: dropped the unreachable `v is None` arm and rejected bool explicitly (isinstance(v, int) accepts bool). - Translated the Russian docstring text to English. - save_ams_mapping's model comment moved to a trailing comment on the column line, matching the file's convention. - usingArchiveMapping now resets when the plate or archive changes, so the Mapping button can't read ON against a mapping it never applied. - Translated "Click to change slot assignment" and "Re-read". - add_to_queue's fallback is now called out explicitly in code comments and covered by three new integration tests (fallback fires, explicit mapping wins, unrelated extra_data doesn't false-trigger). Closes #2684 |
||
|
|
cd7b869419 | Merge branch 'dev' into fix/camera-rotation-finish-photo-timelapse | ||
|
|
432e956eff |
fix(camera): rotate every still exactly once, and cover the sources that let ffmpeg write the file
Review follow-ups on applying camera_rotation to finish photos and layer-timelapse frames. Rotating the frame popped from _stage22_finish_frames rotated one of its sources twice. The cache has two kinds of feeder: live grabs, which are raw, and the #1867 in-print bank, whose bytes come from _capture_snapshot_for_notification and have already been rotated on the way in. The consumer cannot tell them apart, so on the finish_state trigger - the path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180 degree rotation cancelled itself out and the photo was upside-down again, which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now happens where each frame is captured, so every entry in the cache carries one rotation whatever produced it, and the invariant is stated both where the cache is declared and where it is consumed. Two finish-photo sources were still writing unrotated files: the built-in camera's own capture_finish_photo, and the still extracted from a printer-recorded timelapse - which is the *preferred* source for a built-in camera print, so a user with a rotation set got a correctly oriented photo or not depending on which source happened to win. Neither ever holds the frame as bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file handles that case and is best-effort - a failed rotate leaves the unrotated file rather than losing a delivered photo. The archived video itself is the printer's own file and is not re-encoded, so it still plays at the camera's native orientation; the CHANGELOG says so rather than leaving it to be discovered. apply_camera_rotation logs at debug, not info. It was on a path that runs once per layer, where a tall print would have put hundreds of lines in the log for something the surrounding capture already reports at debug. The moved rotation logic had no test of its own - every existing test patches it out and asserts the call, so a flipped sign or a dropped expand=True would have shipped green. test_camera_rotation.py drives the real round trip: a corner marker pins which way it turns, the dimensions pin that the frame is not cropped, and an undecodable frame comes back by identity because a capture path must not lose a frame to a failed rotate. Tests for the fix itself sit on both sides of the cache. The producer half is driven directly; the consumer half is a closure nested inside on_print_complete with nothing able to reach it, so it is pinned by an AST guard - checked against the source because the alternative is no check at all. Reverting main.py to the pre-fix shape fails three of the five, the guard among them. The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit flagged (B108); they take tmp_path now. |
||
|
|
08c9ec6749 |
fix(camera): protect an in-progress stitch from the orphan sweep, and only sweep this feature's own files
Review follow-ups on the orphaned timelapse session cleanup. The sweep's own docstring said min_age_seconds made it safe to call mid-run. It did not. on_print_complete drops the session from _active_sessions before handing frames_dir to ffmpeg, so for the length of a stitch the directory matches no active session, and its mtime is the last layer's frame write - which on a tall print's final layer is easily older than the margin. The default margin is 300s and the stitch timeout is also 300s, so the two were tied with no headroom at all: a sweep landing in that window deleted ffmpeg's input from under it. _finalizing_sessions now covers the stitch, set as the session leaves _active_sessions and cleared in a finally so a failed stitch cannot leak the marker and make that printer's leftovers permanently un-sweepable. The docstring names all three guards and which gap each covers, including that the margin does have real headroom for the two cases it suits - a session mid-creation, and the freshly written .mp4 awaiting attach. The file branch now requires the timelapse_<session_id>.mp4 shape its own comment describes. It previously deleted any file under timelapse_frames/<printer_id>/ past the margin; nothing else writes there today, but age alone is not a reason to delete a file this feature did not create. Dropped ignore_errors=True from the rmtree. It made the surrounding except OSError unreachable, so a read-only mount or a permissions problem was counted and logged as a successful removal - and that log is the only evidence an operator has of what was deleted. Tests 5 -> 9: sparing a session mid-stitch, the finalizing marker cleared even when the stitch raises, unrelated files left alone, and a failed removal not counted. The failure test's rmtree stub honours the real contract and returns silently when ignore_errors=True, because that silent no-op is exactly what the old call could never observe; a stub that raised unconditionally would have passed against both versions and proved nothing. main.py is unchanged: it has no module-level logger, and the inline logging.getLogger(__name__) the sweep uses is the idiom throughout lifespan. |
||
|
|
4bd6761487 | Merge branch 'dev' into fix/timelapse-orphaned-session-cleanup | ||
|
|
4888485a54 |
fix(camera): redact credentials, contain failures, and stop the external-camera test claiming a connection it never opened
Review follow-ups on the external-camera capture coalescing. The coalescing was transplanted from camera.py, which is keyed by printer IP and so has nothing to hide in a log line. These keys carry the camera URL, and an RTSP camera URL routinely embeds user:pass@ - so the five new log lines printed the password, one of them at warning level, where it reaches support bundles. All five now go through _log_key(), which redacts before truncating: slicing first can cut the URL short of the @ the pattern anchors on and leave the password intact, which is why every other URL log in the module already does it in that order. _capture_frame_uncoalesced gained the blanket catch its camera.py counterpart has. That is load-bearing once captures are shared: the wrapper hands one task's outcome to every caller waiting on it and can only give a follower its own turn for an outcome it recognises, so an escaping exception reached all of them at once and none retried - one caller's failure becoming N. The per-type helpers catch narrowly (aiohttp.ClientError / OSError / timeouts), so the guarantee belongs here rather than resting on their coverage. CancelledError is re-raised ahead of it, since the wrapper distinguishes a cancelled leader from a failed one. test_connection reports whether it shared a capture. It reaches capture_frame like any other consumer, so a test landing while Obico is polling got that frame back and answered "connected" for a connection it never made - the one answer a connection test must not give silently. It still shares rather than forcing its own capture, because forcing one would open the second handle to a single-reader device that this whole mechanism exists to prevent. The response carries `coalesced`, which also gives capture_in_flight() the consumer its camera.py counterpart has in the Diagnose tool, and the Test button says "shared with a capture already running" instead of a bare success. Tests 12 -> 20: an unexpected error reported as a failed capture, a raising leader whose follower still gets a frame, the three coalesced states, and redaction on each log line that can carry a URL. The raising-leader test patches _capture_rtsp_frame rather than _capture_frame_uncoalesced, since a stand-in installed in the latter's place sits above the catch and would test the wrapper against a shape it can no longer be handed. |
||
|
|
6ad5bf30f7 | Merge branch 'dev' into fix/external-camera-capture-coalescing | ||
|
|
3daae22f3d | Security hardening (maziggy/bambuddy-security #8) | ||
|
|
449060ce0a | bugfix (aborted prints were not billed in specific cases) | ||
|
|
0d6a28cd67 | Merge remote-tracking branch 'upstream/dev' into feature/billing | ||
|
|
5db4c75ca0 |
fix(camera): apply camera_rotation to layer-timelapse frames too
Same gap as the finish-photo fix: camera_rotation was only ever wired into the notification-snapshot path, so a layer-timelapse video came out upside-down whenever a rotation was configured - every frame (fresh or reused from the live view's buffer) was written to disk raw. Extracts the rotation logic out of main.py into a shared apply_camera_rotation(image_data, rotation, logger) in services/camera.py (taking the rotation value directly rather than a printer object, so both main.py's printer-shaped callers and layer_timelapse's plain int field can use it). main.py's _apply_camera_rotation becomes a thin compatibility wrapper so its existing call sites are unchanged. Threads a `rotation` field through TimelapseSession/start_session, set from printer.camera_rotation in _maybe_start_layer_timelapse, and applies it (via asyncio.to_thread, since PIL rotation is CPU-bound) in capture_layer before each frame is written - after #2707's live_frame_for_capture() resolves the frame, regardless of whether it came fresh or from the live view's buffer. Rebased onto #2707's landed implementation: capture_layer now calls live_frame_for_capture() instead of the older is_stream_active/ try_get_active_buffered_frame pair this was originally written against, so the layer-timelapse tests are updated to match, and the now-redundant TestCaptureLayerAvoidsCompetingWithLiveViewer class (superseded by #2707's own test_external_camera_live_frame_reuse.py) is dropped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |