mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
35405dd79e992301a0aa367239bc089eb4ecccc3
92
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9500c046c0 |
Ask which nozzle to feed when a Filament Track Switch is fitted
Load and Unload in the AMS slot menu did nothing on an H2C with the switch fitted. The ams_change_filament command carries an optional extruder_id and Bambuddy never sent it. That is correct on every printer without the switch, and is what BambuStudio does there too -- each AMS is wired to one hotend, so the firmware works the target out for itself and an explicit value would only be a guess at something it already knows. Fit the switch and every AMS is bound to one of its two inlets instead, either hotend is reachable from any slot, and a command naming neither leaves the firmware nothing to act on. It was discarded in silence. Load now asks which hotend to feed, on the same terms as Bambu Studio: no preselection, so a stray Enter cannot feed the wrong one, and the hotend already fed from that very slot greyed out. Printers without a switch send a byte-identical command and still load in one click. A switch fitted but not yet set up -- any AMS still unassigned to an inlet -- refuses the load up front rather than publishing one the firmware will drop, mirroring DevFilaSwitch::IsReady, which likewise demands a switcher position on every AMS. Unload was addressed at the same time. It was aimed with tray_now, a single value for the whole printer, so on any dual-nozzle machine with both hotends loaded it unloaded whichever that field happened to name regardless of which slot's menu was used. It now names the slot and resolves the holding hotend from device.extruder.info, previously read for temperatures only. That resolution is gated on the printer having reported two extruders: single-nozzle machines do send the block, but nobody has read a single-nozzle snow value off the wire, and staking every X1C, P1S and A1 unload on an unverified encoding buys nothing where tray_now is already unambiguous. Both new state fields ride the WebSocket and are in the broadcast key, and both are computed in the REST status route as well -- that response is what the page has before any push arrives, and leaving them at their defaults would have told a correctly set-up machine that its switch was not set up. Verified on H2C-1, AMS-A slot 3: loaded and unloaded from each hotend in turn, all four correct. Covered by 18 MQTT unit tests, 4 status-dict tests, 7 integration tests and 6 component tests. Two known stragglers, both deliberately left alone. Load on an AMS-HT slot has never worked -- an HT unit is addressed by its unit id rather than ams*4+slot, which these endpoints do not accept -- so unload there keeps the printer-wide form it always used instead of gaining a slot it cannot name. And a slot's K-profile still follows the AMS's plumbing rather than the nozzle just loaded, so loading to the far hotend leaves the other one's calibration bound; that is the same per-nozzle problem the filament and K-profile redesign is scoped to fix. |
||
|
|
935d4b5bbf |
Charge the tray the printer said it used, not the first one loaded (issue #2953)
A sliced file numbers its filaments 1..4; which AMS tray each came from is decided when the job is sent. #2768 gave the Spoolman writer two ways to recover that decision when the print did not come through Bambuddy: the printer's own mapping field, and a colour match of the 3MF's slots against the loaded trays. An A1 satisfies neither. It publishes no mapping field, and it drops the MQTT connection when we subscribe to its request topic, so the slicer's instruction never arrives either. That leaves the colour match, and it compares hex strings exactly. The reporter sliced with a generic black profile against a tray they had set to charged slot 1 to whatever sat in the first tray -- 2.17 g onto a grey PLA+ spool, while the print was fed from tray 3. Their bundle carries the printer's own answer: "Tray change during print: tray=3 at layer=0", recorded 90 seconds in, and read further down the same completion pass by _print_used_tray_keys to decide which slots the print had touched. The same pass then charged tray 0 on a guess, and logged "AMS0-T3: remain% did not fall over the print" about the tray that had actually done the work. _single_slot_tray_from_state adds the third rung. For a print with exactly one slot carrying usage, the one slot came from the one tray, so the printer's tray reporting answers the question directly: the mid-print tray-change log, then the tray loaded at print start, then the current one, then the last real tray seen. That is the ladder usage_tracker.on_print_complete has consulted since it started resolving mappings at completion -- Spoolman users were the only ones not getting it, which is why an install running the built-in inventory has never shown this. On this printer only the first and last rungs can fire: tray_now_at_start is 255 because print start runs before the filament is loaded, and the A1 parks tray_now back at 255 the moment a print ends. Gated on exactly one slot with usage, like the internal writer: a multi-colour print moves tray_now on every change, so one reading cannot then be attributed to one slot. It also declines when the log holds more than one switch, because an AMS-backup runout is split per segment (#1793) and a single-tray mapping would land the whole print on one spool. That gate needed the guess warning to exclude the split path too. The split never reads slot_to_tray at all -- it charges each segment to the tray the printer announced switching to, which is the same evidence this fallback is built on -- so a declined mapping there is not a guess, and calling it one suppressed the archive rewrite for exactly the prints whose attribution is best supported. Nothing covered that combination; a test does now. Where nothing names a tray the positional default still stands, because it is right for an AMS loaded in slicer order. It now says so at warning level so a support bundle carries the reason, and it no longer restamps the archive's filament colour and material from a spool it picked by position. That restamp is what made the fault read as data loss: the grams can be put back, whereas overwriting what the slicer recorded leaves nothing to compare against, and the reporter's archive had already been rewritten from #000000 to the wrong spool's grey. A slot that consumed nothing also stops claiming a tray in the handled set -- it was never charged, so the remain-delta path should stay free to cover it rather than be suppressed by an estimate of zero. The request-topic probe is the same failure reached from the other side. A printer that refuses kills the TCP connection instead of returning a SUBACK failure, so the only signal is "we subscribed, then got disconnected", and that was believed the first time it happened. Every other reason a connection drops inside the same window looks identical -- a network blip, the printer rebooting, the container stopped mid-probe -- and the verdict was cached per serial with no re-probe anywhere, so on a printer that supports the topic one unlucky drop cost mapping capture for the rest of the process and every slicer print after it was charged by tray position. It now takes two consecutive drops, and a disconnect we asked for is not counted. A printer that genuinely refuses answers the same way every time and pays one extra reconnect; one already known to refuse still skips the subscription outright rather than reopening a reconnect loop. |
||
|
|
6988a30eae |
Carry a fault's description in the status response (issue #2926)
The HMS catalogue has been in the backend all along and the status response never carried it, so every consumer that wanted to tell a user why a print halted resolved the same 853 codes from its own duplicate of the same sentences -- this repo's Python table, the frontend modal's, and at least one third-party client whose catalogue exists purely because the server would not say. Each ages separately, and a relay watching a printer could only manage "your printer needs attention" while the server already knew it was "Filament ran out. Please load new filament." hms_errors[] entries now carry a description, defaulting to null so a client that has never seen the field is unaffected. It is resolved where the fault is parsed rather than at the boundary that prompted the request, because there are three serializers of a fault, not one: the status response, the WebSocket broadcast, and the completion payload the queue's failure reason is built from. Adding it to only the first would have handed half the feature to a relay watching the stream, which is the likelier consumer of the three. The queue's failure reason now quotes the resolved sentence instead of looking the code up a fourth time, and the notification path reads it rather than re-deriving. That they cannot report different text for one fault is the point, and a test asserts they agree. describe_fault is the single mapping from either code shape onto the table. An 8-char print_error is the catalogue's MMMM_EEEE key with the separator removed -- the parser derives full_code and that key from the same 32-bit value -- so it resolves exactly. A 16-char hms[] identifier is tried whole and then collapsed to its first and last groups. That collapse is lossy, and keeping it was the decision worth making carefully. #2728 counts 65 documented faults falling onto 0300_0001 alone, so a hit can attribute a neighbour's sentence to this fault, and refusing it looks like the stricter reading. It is not: the notification path, the queue's failure-reason helper and the frontend modal have all resolved hms[] faults this way for as long as they have existed, and it resolves real ones -- a 0500_4038 nozzle mismatch arrives in that shape. Declining to collapse would have stopped describing faults that are described today, silently suppressed the notifications they raise, and left this field null while the UI showed text for the same fault. Narrowing it belongs with #2728, where both key spaces can move together. So the lookup is exactly what it was, verified rather than asserted: a test walks every catalogue code in both fault shapes across all three alert levels and checks the result against the derivation this replaces. A future change to the lookup cannot quietly stop notifications firing. The catalogue ships one language, so the field is English and unlocalized, which the schema and the API reference both say next to it. The camwall feed is deliberately left alone -- it is code-only because its token travels in a URL on a screen, and a readable sentence discloses more than the camera picture already does. The frontend keeps resolving its own text: switching it would change what filterKnownHMSErrors counts across eight call sites, which is #1840 and #2728's argument to have. HMSError.message goes with this -- a text field that was never set or read anywhere, and an invitation to populate the wrong one now that a live description sits beside it. ----- Record a failure code the user can actually look up The queue's failure reason formats a fault's module and error into MMMM_EEEE, and that one derivation never masked the error to 16 bits. A fault arriving from the printer's hms[] array carries its alert level in the code's high half, so the label came out as 0500_24038 -- five digits in a group that has four. It is not a code anyone can find on Bambu's HMS index, and because it matches no catalogue key the sentence explaining the failure was dropped along with it, leaving the bare number alone. The nozzle-size mismatch behind #1111 is exactly such a fault. Reported one way it read "[0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting"; reported the other, the same physical fault read "[0500_24038]" and nothing else. There is already a helper that gets this right, used by the archive's own failure-reason lookup, so this calls it instead of keeping a fourth copy of the derivation. It also takes the raw integer code the MQTT payload carries, which the local version only handled as a string. |
||
|
|
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. |
||
|
|
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. |
||
|
|
a9e23910fc | Merge branch 'dev' into feature/billing | ||
|
|
dd1d40b0d4 |
implemented pr (worth fixing) feedback
update commit |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
c1a4c99059 |
feat(diagnostics): log end-of-print telemetry for finish-photo trigger research (#2547)
The finish photo needs a "printing done, toolhead parked, filament unload not started" moment. stg_cur=22 was meant to be it (#1721) and fires on no model in the field: across 247 support bundles there is not one FINISH PHOTO MOMENT (stage-22), including the 2026-06-13..07-08 window where it was the only pre-FINISH trigger in the code — 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D, all of them the FINISH fallback. A replacement can't be designed from the bundles we have. Out of that window Bambuddy parses only stg_cur and mc_print_sub_stage; every other stage/action field arrives and is dropped unread. The candidates that sound right (print_real_action, mc_action, mc_stage) are absent from A1/A1 Mini/P1S payloads, so none of them can be the universal answer alone. Dump the raw fields for the window between the last object layer and gcode_state=FINISH at DEBUG. Opens on the first end-of-print signal (last layer, progress >= 99, or no remaining time) so a dropped layer_num packet doesn't lose it, logs only what changed frame to frame, closes on the transition out of RUNNING, and arms once per print. Instrumentation only: gated on DEBUG being enabled, read-only against printer state, wrapped so it cannot break ingest, and capped at 400 frames per print. The probed fields are stage codes, counters and bitfields — nothing identifying, and no access code. |
||
|
|
91269f14fe |
fix(mqtt): report why a printer refused the connection instead of looping silently
A printer with a wrong access code gave no explanation anywhere. The connect callback's failure branch was a bare `state.connected = False`, discarding the CONNACK reason code the printer had just sent, so the only trace was paho's follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error", which is exactly what a powered-off printer produces. In the report behind this fix one of three printers had been in that loop for the whole capture, and neither the log nor the support bundle could say why. Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto reason codes 134 and 135. Both are now logged with the printer's own reason string and, for those two, the remedy: the access code is regenerated whenever LAN Only or Developer Mode is toggled, so it has to be re-read from the screen. The access code itself is never logged -- it would land in every bundle. The reason is kept on the client as a stable slug and plumbed through test_connection into the connection diagnostic, which now distinguishes two cases it previously conflated. "The printer refused our credentials" is asserted only when the printer said so; when all Bambuddy knows is that there is no session, the text hedges and names the alternatives (rebooting, or already at its limit of simultaneous connections). The old wording claimed the access code was most likely wrong in both cases. Frontend needed no change -- ConnectionDiagnostic already renders `<status>_<reason>` variants with fallback to the plain per-status text, so an unrecognised slug degrades to today's wording rather than a missing key. |
||
|
|
13183e365a |
fix(ams): clear empty AMS-HT slot via its real tray_exist_bits position (#2670)
After #2594 every empty-slot clearing path skipped AMS-HT units, so a removed HT spool never cleared on the printer card while Bambu Studio correctly showed Empty. Root cause: the HT presence bit is packed as a single consecutive bit in tray_exist_bits at 16 + (ams_id - 128) (HT-A=16, HT-B=17, ...), not the regular ams_id*4 position -- so the bitmask cleanup, which skipped id>=128, never touched it. The HT's state field is firmware-variant (loaded reports 11 on H2D, 9 on the #2594 firmware) and it keeps echoing stale tray_type after removal, so the bitmask is the only reliable, firmware-independent signal. Confirmed against a live H2D capture (loaded=0x10f7f, empty=0xf7f) and the OrcaSlicer DevFilaSystem.cpp reference (is_exists = tray_exist_bits >> (16 + (ams_id-128))). - apply_tray_exist_bits: handle AMS-HT (128-135) at 16+(ams_id-128) instead of skipping it; clears an empty HT and, because a loaded HT keeps its bit set, never wrongly clears one (keeps the #2594 fix intact). Ids outside the known regular (0-15) and HT (128-135) ranges are left untouched rather than guessed. - Build the AMS change-hash from the merged state, not the raw payload, so a removal signalled only by the bitmask (firmware still echoing tray_type) still flips the hash and fires on_ams_change to unbind the spool_assignment row. - Emit the exists presence bit in the websocket status serializer (REST already did) so the card renders "Empty" instead of "?" where state is ambiguous. |
||
|
|
1234f0830a |
fix(mqtt): don't let K-profile responses clobber the nozzle size (#2663)
Fetching K-profiles probes every nozzle size in turn (extrusion_cali_get for 0.2/0.4/0.6/0.8mm), and each response echoes the requested diameter at the top level. _process_message passed every "print" message -- including these responses -- to _update_state, which treats a top-level nozzle_diameter as the installed hardware. So the last size probed (0.8) overwrote the real nozzle in memory; a genuine status push corrected it and the next K-profile fetch broke it again, which is why it flickered between 0.8, empty and the correct 0.4. Since 1.2.5 the #1899 mismatch guard then refused to dispatch, failing prints with a bogus "printer has 0.8mm". Handle extrusion_cali_get responses only via the K-profile parser and skip _update_state for them, mirroring the existing get_accessories guard. The nozzle size now comes solely from the real status push. In-memory only -- affected printers self-correct on the next push after updating. |
||
|
|
56accd24de |
fix(smart-plugs): don't blank printer state when an accessory plug switches off (#2629)
An end-of-print auto-off on a plug that powers a filter fan marked the linked printer offline and forced its state to "unknown". The mark was unrecoverable: connected heals on the next MQTT message but state does not (only frames carrying gcode_state rewrite it, and steady-state push_status frames are partial), so the printer stayed "unknown" until a manual Force Refresh and the queue never dispatched to it again. The offline mark is now an explicit presumption: mark_power_off records the state it overwrites and _on_message undoes it as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut. A reconnect discards the saved state, so a genuine power cut is unaffected. Each plug also gains a controls_printer_power flag (default true, backfilled) that gates all five power-off paths, and the queue's power-on step now picks the flagged plug instead of whichever linked plug came first. |
||
|
|
2e45893dd5 |
feat(print-options): add "Auto" state to bed levelling, flow & nozzle-offset calibration
Bed levelling, flow calibration, and nozzle-offset calibration were on/off only, so the sole way to run bed levelling was to force a full level before every print. Bambu Studio has always offered a third "Auto" state that lets the printer skip the calibration when it was done recently -- the state most users actually want. Make these three options tri-state (off/on/auto), defaulting to auto, and leave vibration/layer-inspect/timelapse as on/off (Bambu Studio exposes no auto for those). Wire encoding follows Bambu Studio's source exactly: each option sends a JSON bool (true only for "on") plus a companion int -- off=0, on=1, auto=2. The bool fields stay booleans (the #1478 H2S regression); only the companion int widened from {0,1} to {0,1,2}. #1721's observation that stage 8/39 stays queued when sending 2 is the auto contract (queued, skipped at runtime if recent), not a broken "off". - schemas: TriState = Literal[off/on/auto] with a BeforeValidator coercing legacy bool / 0-1 / true-false so old clients and un-migrated rows validate - model + migration: boolean columns -> String; SQLite via column affinity + data backfill, PostgreSQL via ALTER COLUMN TYPE guarded on information_schema (verified on both dialects); settings rows normalised true/false -> on/off - MQTT: start_print takes the tri-state strings and emits the paired bool+int - Virtual Printer: reconstructs the slicer's auto/on/off from the int companion (auto_bed_leveling / extrude_cali_flag) in both capture paths - frontend: CalibrationMode type; off/auto/on segmented controls in the print dialog, queue bulk-edit, and Settings -> Workflow; calibrationMode_* strings in all 11 locales |
||
|
|
059b733c41 |
fix(ams): don't wipe an AMS-HT spool on a partial state=9 update (#2594)
The AMS merge clears a tray on a partial {id, state} update when state != 11
(the 4-slot AMS "emptied slot" signal, #784). An AMS-HT (single-tray high-temp
dry box, id >= 128) reports its loaded tray as state=9, so the partial the
printer sends on power-on was misread as "emptied" and wiped the HT-A spool's
tray_type/RFID/assignment seconds after power-on.
Skip the state-heuristic for HT units (id >= 128). Genuine HT removal still
clears via the explicit tray_type="" update and tray_exist_bits cleanup;
regular AMS (id < 128) is unchanged.
|
||
|
|
47a2a77cd3 |
fix(filament): don't dispatch an unresolved AMS mapping to the external spool (#2589)
A P1S queue row with use_ams=true but ams_mapping=[-1] was silently printed with no AMS, starting against the empty external feed and pausing with a runout. Two faults combined: - start_print treated -1 (unresolved) the same as >=254 (explicit external) when deciding to force use_ams=False. Only genuine external now downgrades; -1 never does. - The scheduler trusted a stored [-1] as "already resolved" and passed it through. It now recomputes from live AMS trays whenever the stored mapping is entirely unresolved, and clears it if nothing matches rather than sending a doomed command. Frontend: the Print dialog no longer serializes an all-[-1] mapping while the printer status is still loading (the hook returns no mapping), and submit waits for AMS status with a "Waiting for AMS status" notice. Tests: new backend + frontend regression coverage; corrected one existing test that pinned the old [-1] -> use_ams=False behavior. |
||
|
|
f6c6cfbad3 |
fix(ams): show "?" not "Empty" for non-RFID spools using tray_exist_bits (#2527)
A spool with no readable RFID was reported by the standard AMS with an empty tray_type and state=9 — structurally identical to a truly-empty slot at the tray level — so the AMS card rendered it "Empty" while Bambu Studio correctly showed "?". The authoritative "a spool is physically here" signal is firmware's AMS-level tray_exist_bits bitmask (what Studio uses), but Bambuddy inferred emptiness from the per-tray state/tray_type. Confirmed from the reporter's bundle: tray_exist_bits=f (all four slots present) with tray_is_bbl_bits=5 (only slots 0,2 Bambu) — the present-but-non-Bambu slots were the ones shown Empty. Supersedes closed #1838. apply_tray_exist_bits() already parses the bitmask to clear stale fields on absent slots; it now also annotates each slot with an authoritative `exists` bool, gated behind a new annotate_exists flag so only the printer-card path sets it. The VP bridge leaves it off, so the `exists` key never reaches the slicer wire format. `exists` flows through the AMSTray schema/serialization to the frontend, where getEmptySlotKind() uses it: exists===true + no tray_type -> "?" (present, unconfigured), exists===false -> "Empty", exists absent -> the previous state=9/10 heuristic (AMS-HT and missing-bitmask paths unchanged). H2D/X1C already reported present-unknown slots with a non-9 state and took the "?" path; with the fix they reach it via `exists` and are unaffected. |
||
|
|
2d13e77f1b |
fix(mqtt): capture finish photo on last-layer edge, not FINISH state (#1867)
A1 Mini firmware skips stg_cur=22 entirely, so the finish-photo fallback fires at gcode_state=FINISH — which runs AFTER Bambu Studio has already executed the user's End G-code. Users with SwapMod plate-swap injected into End G-code always got a photo of the swapped (empty) plate. Add a layer_num >= total_layer_num edge trigger in _parse_print_data so the pre-capture fires the moment the last object layer completes, on every printer variant. Guarded by the existing _finish_photo_captured one-shot so stage-22 and FINISH-state hooks become no-ops for the same print — no framing regression on AMS printers without custom end G-code. |
||
|
|
d4ad41d850 |
fix(hms): action buttons actually reach the printer (#1830)
Three distinct bugs combined into one user-facing failure: clicking
Stop / Problem-solved-and-resume / Ignore-and-resume returned 200 OK
but the printer didn't act, modal stayed up, print stayed paused.
Verified by injecting candidate command shapes on device/<sn>/request
against a live H2D paused on a wrong-plate HMS (print_error=0x05008051).
(1) hms_resume / hms_stop dispatched the "err"-bearing shape that
BambuStudio doesn't actually send; Bambu firmware silently rejects it.
Both now send the plain shape ({"print":{"command":"<x>","param":"",
"sequence_id":"0"}}). PAUSE -> FAILED in 1.7s for stop, PAUSE -> RUNNING
in <2s for resume.
(2) IGNORE_RESUME mapped to idle_ignore, which is BambuStudio's
"dismiss a warning" command and only works for non-pause warnings.
hms_ignore now branches on state.state == "PAUSE": paused -> plain
resume; not-paused -> idle_ignore with the full-length err.
(3) 64-bit hms[]-array faults were truncated to a non-matching err.
short_code in _parse_status discarded 32 of the 64 identifier bits, so
the firmware didn't match it to the active fault. HMSError.full_code
now carries the canonical hex identifier (16 chars for hms[] faults,
8 chars for print_error faults). Catalog lookup tries 16-char first,
falls back to 8-char. HmsActionBody.print_error pattern relaxed to
^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$.
(4) execute_hms_action returned publish-success as success, masking
every silent-rejection bug above as 200 OK. Route now snapshots
(state.state, len(state.hms_errors)) before dispatch, awaits
HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests
override), and returns 502 with "Printer did not acknowledge HMS
action within 2.5s" if state didn't move.
|
||
|
|
4c67d8a4e1 | feat: Unify print dispatch through the scheduler (#1625) | ||
|
|
fd61812d01 |
feat(drying): show active-cycle filament + target temperature on the AMS drying badge
Bambu's per-tick AMS push carries only the dry_time countdown — the
filament name and target temperature the user chose are never echoed on
the wire. The AMS card had no source of truth for them and rendered the
bare "Drying · 11h 35m left". The badge now shows
"Drying · PETG @ 65°C · 11h 35m left", matching the cycle the user
actually started.
BambuMQTTClient caches {ams_id: {filament, temp}} on send_drying_command
(mode=1), clears on mode=0 and on the dry_time falling edge to 0 — the
same per-AMS edge detector that drives the smart-plug-after-drying
callback. PrinterManager.get_drying_targets exposes it, the four
printer_state_to_dict call sites thread it through, AMS schema gains
dry_target_temp + dry_filament, and routes/printers.py builds the same
fields into the manually-constructed AMSUnit response.
When no cached target exists (drying started in a previous backend
lifetime, or initiated outside Bambuddy), the badge falls back to the
first loaded tray's tray_type + RFID-recommended drying_temp — the
heuristic the popover already uses to seed defaults.
i18n: printers.drying.targetSummary = "{{filament}} @ {{temp}}°C" in
all 11 locales. Parity check 5356 leaves per locale.
Note: a user reported the H2D's own physical display still labels the
cycle by the loaded tray's filament (e.g. "PLA" instead of the
Bambuddy-requested "PETG"). The wire payload is correct end-to-end —
journalctl shows filament: "PETG" sent and result: success ACKed — and
the badge in Bambuddy's own UI now reflects what we actually sent,
independent of the firmware's display choice.
|
||
|
|
4b0150b0ec |
fix(mqtt): close #1822 — promote H2S tray_now to 254 on all-external prints
H2S firmware reports tray_now=0 (the AMS's idle slot) throughout external-spool prints instead of 254 like X1C/P1S/A1 do, so the single-nozzle branch's 0-3 passthrough landed state.tray_now on slot 0 — UI highlighted AMS SLOT 1 instead of the external spool. Usage credit was unaffected (#1276 covers that via ams_mapping). The single-nozzle branch now checks _captured_ams_mapping (slicer- captured per-filament mapping that the request-topic intercept already tracks) before the existing P2S multi-AMS resolver. When every entry is -1, the print uses ONLY the external spool, so state.tray_now is promoted to 254. Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT overridden — we have no evidence H2S misreports mid-print swaps, and trusting the firmware preserves correctness for users with multi- filament setups. No-mapping prints (printer-screen start) fall through unchanged. |
||
|
|
166e9f9ef2 |
fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field
First-attempt fix ( |
||
|
|
a53dc20ca3 |
fix(usage-tracker): split mid-print AMS-Backup spool switch correctly (#1771)
Reporter forcefully started a print needing ~260 g with 180 g on the
first spool and a backup spool in the AMS. Printer correctly consumed
spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
attributed all 260 g to spool 2 -- spool 1 untouched in inventory.
Two stacking bugs produced the exact "all to second spool" symptom for
prints without per-layer 3MF gcode data:
1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
unconditionally. P1S firmware pushes total_layer_num=0 at print end
(same reset pattern other models do for layer_num / progress). The
unconditional write clobbered the slicer's actual total to 0 before
the usage tracker read it.
2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
last segment when total_layers was 0:
if total_layers > 0:
segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
else:
segment_grams = 0.0 # <- entire print weight ends up on last segment
Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
spool reported remain=-1 and (b) Bug-A had already added the second
spool's key to handled_trays, suppressing the Path 2 lookup.
Fix:
- bambu_mqtt.py: only overwrite state.total_layers when the incoming
value is positive (mirror of the existing _last_valid_layer_num
pattern at line 2127). Explicit reset on new print start at
_handle_print_start so the previous print's total can't bleed in.
- usage_tracker.py: cascade the linear-fallback denominator -
state.total_layers, then last_layer_num (already threaded in for
the last_progress fallback), then equal-split as a bounded fence.
Equal-split is still wrong but never dumps the whole print on the
last segment, which was strictly worse.
|
||
|
|
b1cb26f6ee |
feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup status/control surface, and the #1766 fix that depends on it. Added -- AMS Filament Backup status + control - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup on every push_status. Verified against OrcaSlicer source (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state (None = A1 family / pre-cfg push) preserves today's behaviour. - Hold-timer guard (3 s) prevents stale frames from flickering the badge back to the printer's old cfg after a user-initiated toggle. - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client method calling _set_print_option("auto_switch_filament", enabled). - GET /printers/{id}/inventory-remain endpoint exposes the same map the dispatcher uses (internal and Spoolman modes both work uniformly). - Small icon badge in the printer card's "Filaments" section header (placement reads as printer-wide because the cfg bit is printer-wide, not per-AMS). Click to toggle, success toast. - 5 i18n keys x 11 locales for the badge UI. Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest to False when status.ams_filament_backup is False; log the skip. - New effectivePreferLowest(setting, backup) helper applied at every frontend sort entry point: single-printer PrintModal, multi-printer hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping standalone editor (the last had NO preferLowest awareness at all before this change). - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier key exactly, including the banding tie-break (regular AMS < AMS-HT < external) so the client-side pre-compute matches the dispatch-time pick. An earlier draft used a flat `amsId * 4 + trayId` priority which gave external slots (ams_id = -1) a NEGATIVE priority -- caught in code review before commit. - Settings -> Filament -> "Prefer lowest remaining filament" gets an explanatory note about the printer-side AMS Backup dependency, with i18n key in all 11 locales. |
||
|
|
d196cfc500 |
fix(virtual-printer): forward H2C rack-swap nozzle pick from slicer to dispatch (#1780)
BambuStudio's project_file MQTT command for O1C2 (the H2C dual- nozzle-rack variant) carries nozzle_mapping (per-filament physical nozzle position IDs) and nozzles_info (per-extruder rack metadata). The VP intake was dropping both, so the H2C firmware fell back to "last matching nozzle type" auto-pick and ignored the user's slicer choice — every HF print landed on R2, every standard print landed on R4. Carry both fields through the VP intake → queue item → MQTT dispatch path. New nullable TEXT columns on print_queue, non- branched ALTER (matches ams_mapping / filament_overrides precedent). Dual-nozzle gate at start_print() keeps the fields off single-nozzle dispatches. Fail-open on malformed JSON — firmware auto-picks, never worse than pre-fix. Stamps both fields on every plate in the multi-plate Send All loop (#1697 / #1188 precedent). ams_mapping2 still handles H2D/X2D dual-extruder routing unchanged; this fix is scoped to the O1C2 rack-swap mechanism. |
||
|
|
8a63fcbf57 |
fix(vp): apply tray_exist_bits empty-slot cleanup to slicer-facing cache (#1726)
VP bridges bound to a target printer (Proxy mode, Queue mode with specific target) forwarded the printer's raw AMS push_status to the slicer untouched. bambu_mqtt.py::_handle_ams_data applies a tray_exist_bits-driven cleanup to Bambuddy's internal state (promote empty slots to state=9, wipe stale tray_type / tray_color / tray_info_idx / tag_uid / tray_uuid / remain) so the AMS card renders empty slots as Empty, but the VP bridge cache never ran the same cleanup. Net result on real hardware: a printer with 3 loaded filaments and several previously-loaded-now-empty slots had Bambuddy's AMS card render those slots correctly as Empty, but BambuStudio after Sync painted them as phantom loaded filaments with stale color and material from before the slot went empty. Root cause: two consumers of the same payload, only one wired to the cleanup. _handle_ams_data ran it on every push; mqtt_bridge.py:: _on_printer_raw merged the ams blob via _merge_ams_dict but copied tray_exist_bits through as an opaque scalar without acting on it. Fix: factored the bit-clear logic out of _handle_ams_data into a module-level helper apply_tray_exist_bits(units, tray_exist_bits_str, *, power_on_flag, log_label). Internal path replaced with a single call. Bridge calls it after _merge_ams_dict on the merged ams dict, before the merged state is stored as the 1 Hz cached-as-base source. Shared shutdown guard kept on both sides: all-zero bits + power_on_flag=False is the printer-off pattern (#765, would propagate phantom empties on every reconnect); nonzero bits + power-off is valid idle-printer state (#1365, X1C between prints) and still applies. AMS-HT units (id >= 128) skipped on both sides. Tests: new TestApplyTrayExistBitsHelper (10 cases) pins the helper contract directly. 3 new bridge regression tests reproduce the #1726 wire shape, the shutdown guard, and the AMS-HT skip on the cached slicer-facing state. Existing internal-state tests for the bit-clear logic (covers state=9 promotion, loaded-slot preserve, genuine-removal-with-power-on) continue to pass against the refactored path. One pre-existing bridge fixture had an inconsistent tray_exist_bits ('3' for 2 AMS units each with slot 0 loaded — bit 4 missing). The shared cleanup exposed it; corrected to '11' (bits 0 + 4) to match real-printer wire shape. Reported by @needo37 with full code-level analysis including the suggested fix shape and the BAMBUDDY_VP_DUMP_WIRE diagnostic to verify on a live system. |
||
|
|
0de71ca412 |
fix(printer): "off" flow_cali / nozzle_offset_cali now actually suppress the stage
The Re-print and Schedule modal toggles for Flow Calibration and Nozzle Offset Calibration accepted "off" correctly and flowed it through to the project_file MQTT publish — Bambuddy sent extrude_cali_flag: 2 and nozzle_offset_cali: 2 per the "1 = run, 2 = skip" reading inherited from the #1478 / #1682 work. Live test on H2D 01.x: with both toggles off, the stg queue still scheduled stage 8 ("Calibrating dynamic flow") and stage 39 ("Nozzle offset calibration"), and the printer ran both at print start. Root cause: 2 means "skip the explicit pass but still apply / verify stored PA via the calibration stage" — close to a no-op K-factor wise but the per-print physical sequence still runs. 0 is the encoding that actually drops the stage from stg. A BambuStudio Send-dialog capture on the same firmware showed 0 for both fields when the user unchecked the calibrations — contradicting the #1478 commit's read of "BambuStudio never sends 0." Fix: - extrude_cali_flag = 1 if flow_cali else 0 (was: else 2) - nozzle_offset_cali = 1 if (nozzle_offset_cali and is_dual_nozzle) else 0 (was: else 2) Dual-nozzle gate stays; single-nozzle prints continue to force-skip the nozzle-offset cali their head doesn't support (#1682). The 1 (run) branch is unchanged. Verified live on the same H2D after the patch: stg dropped to [29, 13, 4, 14, 3] (cooling, homing, filament change, nozzle cleaning, vibration comp). Stages 8 and 39 gone. Vibration compensation is NOT fixed by this commit: vibration_cali is a bool in our and BambuStudio's wire format, and the H2D firmware queues stage 3 regardless of the false value. Firmware-side, not solvable at the dispatch layer with the current field. Filed as a follow-up. |
||
|
|
e01d3a7979 |
feat(mqtt): one-shot device identification probe for unknown models
Logs device.dev_model_name / dev_product_name / dev_id / project_name at INFO level once per client session, falling back to device.keys() if none of the known fields are present. The MQTT push_status carries the model code in device.dev_model_name on every message, but nothing in bambu_mqtt.py reads or logs that field — so adding a new printer model meant chasing the code through either Bambu cloud or a manual mosquitto_sub. A2L (#1684) was the case that surfaced this: get_version also failed because the firmware disconnected right after request topic subscription, so the support bundle had no way to disclose the model. INFO level so the line lands in support bundles without enabling debug. One-shot via _device_id_logged, mirroring the existing _nozzle_fields_logged flag at line 2095, so push_status spam is avoided. Future-proofs against Bambu renaming the field (the fallback dumps device.keys() so a rename like model_name without the dev_ prefix is still observable). 3 unit tests in TestDeviceIdentificationProbe pin all three branches. |
||
|
|
2c2725cb53 |
fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682)
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip), giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle BambuStudio exposes. Critical for diamond-nozzle setups that must keep the calibration off. start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as 1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines always send 2 even if a stale flag arrives. The kwarg threads through printer_manager, both background_dispatch sites, and print_scheduler so every dispatch path respects the per-item setting. print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite() branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali defaults to TRUE to match BambuStudio. Schemas updated across print_queue, library FilePrintRequest, archive ReprintRequest, settings. PrintModal renders the new toggle only when the selected printer is dual- nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS). SettingsPage default-print-options row + QueuePage bulk-edit tri-state both hide unless any registered printer is dual-nozzle. Labels reuse the existing settings.default* keys so the only new i18n strings are settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali - real translations in all 11 locales. |
||
|
|
ed08ed3787 |
fix(timelapse): capture baseline on restart-recovery so post-reboot timelapses attach (follow up issue #1485)
When Bambuddy is restarted mid-print, the first MQTT push from the printer carries `_previous_gcode_state = None`. The #1304 guard deliberately suppresses on_print_start on that first push to prevent duplicate archive creation — but on_print_start is also where _capture_timelapse_baseline_at_start runs, so the in-memory _timelapse_baselines dict stays empty for the resumed session. At PRINT COMPLETE, _scan_for_timelapse_with_retries finds no baseline and falls into its "take baseline now" fallback. By that point the printer has already uploaded the in-flight MP4, so the snapshot includes the new file. Every "Found N files / no new files since baseline" retry then fails to detect a diff, and the archive ends up with no timelapse attached — pwostran's report (#1485 follow-up): card shows the finish snapshot but no video. Add a sibling callback on_print_running_observed that bambu_mqtt fires in the "Now tracking RUNNING state" branch when on_print_start was suppressed. main.py wires it to a thin handler that looks up the printer row and calls the existing _capture_timelapse_baseline_at_start. Idempotent — skips if a baseline already exists (handles the rare same-session race where on_print_start also fires for some reason). The printer doesn't upload the timelapse until after PRINT COMPLETE, so a baseline captured any time during the print is still pre-upload — no narrow window to hit. Verified against the in-the-field logs in #1485 (pwostran's 2026-05-23 support bundle): pre-reboot: baseline = 7 files reboot post-reboot completion: fallback baseline = 8 files (includes new MP4) -> all 4 retry attempts report "no new files since baseline" 10 new tests cover both the MQTT-side fire decision (fires when suppressed, doesn't fire when on_print_start handles it, once per session, payload shape mirrors on_print_start) and the main.py handler (snapshot capture, double-capture guard, missing-printer-row guard). |
||
|
|
745ed847e6 |
fix(archive): stop duplicating the job on a backend restart mid-print (#1485)
A restart during an active print duplicated the running job in the
archive, and every further restart spawned another. on_print_start
re-attaches by subtask_id, falling back to a name match plus a 4-hour
staleness cutoff that cancelled + recreated any name-matched 'printing'
archive older than 4h - destroying the live archive of every long print.
Two fixes:
- start_print records the minted subtask_id (last_dispatch_subtask_id);
on_print_start falls back to it when the printer hasn't echoed one
yet, so queue/scheduled archives persist a restart-stable id.
- Replace the 4h cutoff with a progress-aware check: a name-matched
'printing' archive resumes whenever the printer reports real (or
unknown) progress; it is stale only when the printer shows a
freshly-started print (<1%) on an archive over 2h old.
|
||
|
|
379b1c14bc |
fix(printer): Flow Calibration was silently skipped — wrong project_file fields (#1478)
The H2S never ran flow-dynamics calibration even with the print option
enabled, because start_print built the project_file command wrong:
- extrude_cali_flag was hardcoded to 0. A BambuStudio request-topic
capture from a real H2D (plus X1C/P2S captures) shows it is always 1
(run calibration) or 2 (skip, reuse stored PA), paired with flow_cali,
never 0 — so the printer skipped calibration regardless of the toggle.
- flow_cali and the other calibration/leveling fields were integer-
encoded for the H2 family on a mistaken belief that H2 firmware
requires 0/1. The same capture sends plain JSON booleans for every
model; the belief conflated these fields with use_ams (which does
need to stay boolean — the actual #1386 cause).
Fix: extrude_cali_flag = 1 if flow_cali else 2; send timelapse,
bed_leveling, flow_cali, vibration_cali and layer_inspect as booleans
for all models; drop the is_h_family integer-conversion branch.
use_ams is unchanged.
Corrected the two tests that asserted the integer format and renamed
them; all three model tests now also assert extrude_cali_flag.
|
||
|
|
01787eb1e6 |
fix(printers): normalize serial numbers + diagnose connect-but-no-reports (#1465)
Reporter's H2C connected over MQTT+TLS but every status field stayed
unknown. Root cause was layer-8: the MQTT broker is the printer, it
authenticates on the access code and SUBACKs any topic string, so a
wrong or mis-cased serial connects fine and silently receives nothing
(the report topic device/<serial>/report is case-sensitive; Bambu
serials are uppercase). Bambuddy stored and used the serial verbatim.
- schemas/printer.py: field_validator strip()+upper()s serial_number on
create, rejects blank-after-strip. The subscribed topic now always
matches the printer's correctly-cased one.
- bambu_mqtt.py: count report-topic messages per connection; when a
stale reconnect fires with zero reports received, log a one-shot
hint pointing at the serial number instead of looping silently.
|
||
|
|
76582298b5 |
fix(drying): stop false "drying complete" from killing the printer via smart-plug auto-off (#1462)
Reporter on X2D set a 1h AMS dry; the printer powered off seconds in.
Support log: every "Sent drying command duration=1" was followed 3-9s
later by "AMS 0 drying complete (dry_time 60 -> 0)" -- the completion
callback fired right after drying started, arming smart-plug auto-off.
Root cause: the tray-bearing branch of the AMS partial-update merge
rebuilt the unit as {**ams_unit, "tray": merged_trays}, never spreading
existing_unit. Tray-bearing partials carry no drying fields, so dry_time
(and info) was dropped; the falling-edge detector read the absent field
as 0 and saw a false 60->0 edge.
- Merge: tray branch now spreads existing_unit first, preserving
dry_time / info / humidity / temp across tray-only partials. Matches
the no-tray branch. Also fixes dry_status/dry_sub_status UI flapping.
- Detector: only evaluate the falling edge when dry_time is explicitly
present and parseable; skip otherwise without updating state.
|
||
|
|
e2df0fc601 |
fix(ams): physically-empty slots report state=9 and render distinctly from reset slots (#1322 follow-up)
Two-part fix for the #1322 follow-up by @RosdasHH. Data layer. The previous narrow heuristic in printer_manager.py only caught the bare {"id": N} payload firmware sends right after a printer restart. In steady-state operation — and on the more common post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a populated payload and signals emptiness via the tray_exist_bits bitmask. We already parse that bitmask and use it to wipe stale tray_type / tray_color / tag_uid fields, but never touched the state field, so downstream readers (printers.py API serializer, inventory.py's tray_state in {9, 10} short-circuit, AMS card) saw state: null and had to guess from absent payload fields. Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358 uses == not `in {...}` so a string would silently miss and the reporter's deadlock would come back) to the outer `if not slot_exists` branch, so the bitmask path now writes the canonical "no spool" code for every empty slot regardless of stale fields. The narrow heuristic in printer_manager.py:797 stays as belt-and-suspenders for any MQTT path that doesn't flow through _handle_ams_data. UI layer. With the data flow now consistent, the AMS slot card renders physically-empty slots distinctly from reset slots, per reporter's mockup. New helper getEmptySlotKind(tray) returns "physical" (state ∈ {9, 10}), "reset" (any other empty state), or null (loaded). The inline label below the slot circle reads "Empty" for physical and "Reset" for reset; pre-fix both showed an em-dash. FilamentSlotCircle gains an emptyKind prop that picks a quieter dashed border colour for reset slots so the visual hierarchy reads loaded > reset > physically empty. EmptySlotHoverCard gains a kind prop and switches between "Empty slot" and "Slot reset — no spool assigned". |
||
|
|
6f2cec5eb3 |
feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing print-finish auto-off but triggered when AMS drying ends. Two new SmartPlug columns: auto_off_after_drying (default false), off_delay_after_drying_minutes (default 10 — AMS chamber is hot post-cycle so longer cooldown than the print-finish default of 5). SQLite + Postgres migrations both idempotent. Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times tracks the dry_time > 0 → 0 falling edge and fires a new on_drying_complete(ams_id) callback. Plumbed through PrinterManager.set_drying_complete_callback to SmartPlugManager.on_drying_complete(printer_id, db), which walks linked plugs and respects the per-plug toggle. Catches queue, ambient and manual drying identically because it observes firmware state, not scheduler intent. Frontend: single "Auto Off After Drying" toggle + delay input on the smart plug card, next to the existing print-finish auto-off section. Per-AMS plug routing (separate plug for AMS only, per-AMS targeting on dual-AMS printers) deferred — Bambuddy's plug model is plug→printer, so the trigger fires whenever any AMS on the linked printer finishes a cycle. |
||
|
|
96fd4bb7e3 |
fix(printer): H2S could not start prints without AMS — was misclassified as dual-nozzle (#1386)
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
and the reporter's diagnostic) but had been added to the H-family model
gate in start_print_job. That single flag controlled both the firmware
bool->int format (legitimately needed for the whole H-family, including
H2S) and the dual-nozzle external-spool routing (correct only for actual
dual-extruder printers).
With no AMS attached and an external-spool slot (tray_id=254), the
dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
canonical 255 — exactly the failure the comment six lines above warns
against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
AMS mapping table". The use_ams=False fallback was also being skipped
because the H-family bypass was meant for dual-nozzle routing.
A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
shares prefix "094" with H2D, so prefix detection misclassified it too.
Split the conflated flag into two:
- is_h_family — firmware format (int 0/1 for calibration fields).
Includes H2S. H2S firmware structurally accepted the current command
shape (failure was at AMS routing, not parsing), so the int format
stays for H2S.
- is_dual_nozzle — external-spool routing and use_ams gating. Excludes
H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
device.extruder.info, with a model-name fallback for the brief window
after connect before push data arrives.
The K-profile delete site and the kprofiles route now use the same
runtime+model check instead of serial prefix.
|
||
|
|
7aa5ff0156 |
fix(ams): detect spool removal on X1C firmware that reports power_on_flag=False (#1365)
The #765 guard against shutdown-time data wipes skipped any AMS update with power_on_flag=False, but some X1C firmware emits power_on_flag=False while idle with tray_exist_bits still reflecting the real slot inventory. Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so the bitfield path is the only signal — muting it left spool removals undetected until a manual reconnect. Narrow the skip to the exact shutdown pattern: zero bits AND power_on_flag=False. Non-zero bits with power_on_flag=False are now applied. The #765 shutdown protection is preserved (its regression test uses tray_exist_bits='0' and still passes); newer firmwares are unaffected because their per-tray state path catches the removal first. |
||
|
|
82c90c6387 |
fix(mqtt): skip print-start fire on first RUNNING after Bambuddy startup (#1304)
Restarting Bambuddy mid-print misfired the plate-check + archive flow. The is_new_print guard treated _previous_gcode_state=None → RUNNING as a transition, but None just means we haven't seen any prior state yet — catch-up from a printer that was already running, not a fresh start. Add `_previous_gcode_state is not None` to the guard. _was_running still flips on unconditionally, so completion detection is unchanged. 3 tests that asserted the buggy behavior now seed an explicit prior state; new regression test pins the contract for the reporter's exact scenario. |
||
|
|
7596725550 |
fix(mqtt): external-spool ams_filament_setting must use global tray_id (#1279)
ams_set_filament_setting and reset_ams_slot encoded the single-external
case as {ams_id: 255, tray_id: 0, slot_id: 0}. The "LOCAL tray_id = 0"
comment was a misread of the printer's response (which echoes the local
slot position), not the request semantics.
Captured BambuStudio -> X1C exchange shows the request encoding is
{ams_id: 255, tray_id: 254, slot_id: 0} (global tray index in tray_id).
The previous code's tray_id: 0 is what the P1S in #1279 rejects with
result: "fail", which silently broke external-spool filament selection
on every Bambu printer with no AMS or external spool in active use.
Dual-external (H2D) branch was not in the captured exchange and is
explicitly pinned at the legacy encoding pending a Studio -> H2D capture.
|
||
|
|
c6e6c4cdd9 |
fix(usage-tracker): split filament weight when AMS auto-falls-back mid-print (issue 957)
When one spool ran out and the AMS transparently switched to a sibling
slot of the same material, the usage tracker credited the originally-
mapped spool with the full 3MF estimate AND added the fallback spool's
remain%-delta on top — so a 78g print could record as 138g across two
spools, leaving the empty spool's recorded weight beyond its label.
Two interacting bugs:
1. bambu_mqtt.py: the tray-change recorder gated on
`state in ("RUNNING", "PAUSE")`, but P2S firmware briefly transitions
out of RUNNING during the AMS swap (into LOADING etc.), so the
literal-string gate missed the switch entirely and tray_change_log
stayed empty. Re-key on the print-lifecycle flags
(_was_running and not _completion_triggered) so any tray change
between print start and completion is captured regardless of the
momentary gcode_state.
2. usage_tracker.py: the splitting branch was gated on
`not slot_to_tray`, so the splitting code only ran for prints where
the slicer mapping hadn't been captured — i.e. never on the actual
fallback case (slot_to_tray is populated by every print_cmd). Drop
the gate: when tray_change_log has > 1 entries, splitting takes
over and per-segment per-layer gcode usage replaces the stale
mapping. Path 2 (AMS remain%-delta) then naturally skips both trays
because they're already in handled_trays after splitting,
eliminating the double-credit.
|
||
|
|
3320c7fd45 |
feat(printers): AMS slot Load / Unload from the printer card (#891)
The ams_load_filament / ams_unload_filament MQTT primitives existed
in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
both as POST /printers/{id}/ams/load?tray_id={int} and
POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.
Wire them into the existing AMS slot popover (next to "Re-read RFID")
and add a popover wrapper on the external spool slot which had none.
Hidden while the printer is RUNNING, mirroring the RFID re-read
gating. Both buttons enabled when permission is granted; the printer
no-ops gracefully if there's nothing to do (matches BambuStudio).
Dual-extruder H2D Ext-R support is the trickier piece. The existing
ams_load_filament(254) capture came from a single-extruder printer
and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
extruder index, NOT a slot index), target=255, and curr/tar = the
actual right-nozzle temp (read from state.temperatures["nozzle_2"],
falling back to 215 °C if cold so the printer doesn't reject the
command on a nonsensical temp). Added that as a new branch in
ams_load_filament; the existing tray_id=254 branch is preserved
verbatim — no risk of regression on single-external setups.
|