diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c078165..ccf212f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file. - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives. ### Fixed +- **Every print archived from a Bambu slice had no bed temperature, so preheat guessed one (#2989, reported by @senguendk)** — `PrintArchive.bed_temperature` was parsed by looking for a `bed_temperature` key in the project settings. BambuStudio does not write one: it stores a separate per-filament array for each plate type — `cool_plate_temp`, `eng_plate_temp`, `hot_plate_temp`, `textured_plate_temp`, `supertack_plate_temp` — and names the plate the project is sliced for in `curr_bed_type`. `bed_temperature` is the Orca/PrusaSlicer spelling, so the lookup matched nothing at all: **0 of 455 real 3MFs** on a live install produced a bed temperature, and every archive stored NULL. That is why preheat logged "archive has no bed_temperature metadata" on jobs sliced perfectly normally, and then heated the bed to the configured keep-warm temperature — 90°C by default — for prints that asked for 55°C. The extractor now reads the array the fitted plate points at, taking the first-layer value in preference since that is what the printer heats to before the print starts; the same 455 files now all resolve, across the four plate types they were sliced for. The plate names and the plate-to-key mapping are BambuStudio's own, so a plate a future release adds reads as unknown rather than silently taking another plate's temperature — as does `Default Plate`, which BambuStudio maps to no key. The per-filament array resolves to its highest entry rather than its first: the bed has one temperature and a 0 in that array means "this filament cannot print on this plate", so the first entry would record a cold bed for any project whose first filament is not one the fitted plate is heated for. An array that is all zeros is left unrecorded rather than stored as 0. Orca-exported 3MFs keep parsing through the generic spelling, which is now the fallback rather than the only thing tried. 21 regression tests. - **A manual K-profile calibration left a print in your archive** — Bambuddy already recognises the printer's automatic pressure-advance run, `auto_pa_line_calib_mode`, and skips archiving and notifying for it. Started by hand instead of automatically before a print, the same calibration reports under its own name with no `auto_` prefix — and manual flow dynamics has *two* shapes, a line and a pattern, so neither `pa_line_calib_mode` nor `pa_pattern_calib_mode` matched anything. Both arrive exactly the way the automatic one does — a bare subtask name with no `/usr/` path — which meant the archive path swept FTP for a 3MF that cannot exist and then wrote a no-3MF archive named after the calibration, on a printer that was in the middle of calibrating. Both are now on the same list, which is the one place both the print-start and print-complete callbacks consult. Matching stays exact after normalising path, suffix and case, so a file you deliberately named `pa_pattern_calib_mode_v2.3mf` — or your own `pa_bracket.3mf` — is still archived as the print it is. Wiki updated. - **A spool you assigned to an AMS slot unassigned itself seconds later (#2987, reported by @frethop)** — and the slot's colour changed at the same time. It looked like Bambu Studio and Bambuddy fighting over the slot; the reporter's log shows Bambuddy losing to itself. P1S firmware 01.10.00.00 reads every **lowercase** hex letter in an AMS `tray_color` as a zero, and hides it completely: the command response echoes back the value you sent and reports `result: "success"`, so only the next AMS push reveals what was really stored. The spool-assign path sent `spool.rgba` verbatim, and that column stores lowercase — so `09ff00ff` became `09000000` on the printer and `ff5100ff` became `00510000`, while the one uppercase write in the same window round-tripped intact. That is the visible colour change. It is also what deleted the assignment: the auto-unlink sweep asks whether the slot still matches the spool it is assigned to, the mangled colour no longer did, and the assignment Bambuddy had created four seconds earlier was removed. Re-assigning could not help, because the **Configure Slot** dialog seeds its colour from whatever the printer currently reports — so it wrote the mangled colour straight back and cemented it, which is the loop in the report's steps 4 and 5. Colours are now uppercased at the single point the MQTT command is assembled rather than in each of the four routes that configure a slot, because a caller that forgets is exactly how this arrived. Nothing else about the command changes: no padding, no invented alpha, and `tray_type` / `tray_sub_brands` keep their case, where it carries meaning. **Two more things found in the same log.** A spool with a brand but no subtype was configured with the literal string `None` in its name — `"Sunlu PLA Matte None"` went on the wire, because the branded branch interpolated the subtype without checking it while the unbranded branch guarded it. And the FTP log is now readable: a `426` whose bytes Bambuddy has verified against the printer is how Bambu's FTPS normally ends a transfer, not a fault, so it is logged at INFO instead of WARNING. It fired 54 times in this one bundle, every single one followed by a completed upload, and it was burying the 26 TLS handshake failures in the same log that actually cost the reporter two prints. A `426` whose bytes do **not** verify is still an error and still fails the upload. 24 regression tests. - **One ASA spool parked in the AMS added 20 minutes to every PLA print (#2886, reported by @FirstRulez)** — Preheat's chamber target was the maximum across *every loaded AMS tray*, with no reference to the job. The reporter's P2S holds PETG Pro, PLA, ASA and PETG; the ASA row of the filament map says 45°C, so a PLA-only plate was dispatched with `chamber_target=45°C`, the bed driven to 90°C to reach it, and the full 900s max-wait plus 300s soak burned before the upload even started — every time, because a P2S has no chamber heater and the chamber tops out around 33°C, so the wait can only ever end on the timeout. Their log carries fifteen of these. The intent was never in doubt: the resolution order documented one screen above reads "PLA-only print derives 0 → chamber phase auto-skips", but it was implemented as PLA-only **AMS** rather than PLA-only **print**, and only misfires on a mixed-material load. The derivation now reads the trays the item's `ams_mapping` actually names — the same array the print command puts on the wire, `[-1, -1, -1, 1]` in their case, addressing exactly the PLA slot — so the ASA two slots over contributes nothing and the stage skips outright. Multi-material prints are unaffected: the maximum is still taken, just across the trays the plate loads, so an ASA the print really does use is still the binding constraint. An item whose mapping is missing or still unresolved keeps the whole-unit scan, since that is the only signal left and narrowing to nothing would disable preheat for prints that need it. The bed hold between jobs (`queue_keep_bed_warm`) is gated on the same derivation and was holding beds at 90°C for the same wrong reason; it now reads the next item's mapping too. **The external spool is no longer invisible to this**: the scan only ever looked at `raw_data['ams']`, so an ASA print fed from the external feed derived 0 and got no preheat at all — a mapping that names 254/255 is now honoured, while an item with no mapping still derives from the AMS alone so nothing starts preheating that did not before. That covers the mappings Bambuddy builds itself, from the print dialog or the dispatcher's own matcher. It does not cover a mapping captured from a slicer through a Virtual Printer: BambuStudio writes the external spool as `-1` there, which is the same value it writes for a slot the plate does not use, so the two cannot be told apart. A wholly external one carries no usable mapping and falls back to the whole-unit scan as before; a mixed one derives from its AMS trays and the external half stays unread — which is exactly what it did before this change, since nothing ever read `vt_tray`. 29 regression tests, built from the trays and mapping in the reporter's own support bundle. diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index a1c65f966..e6a61c6c1 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -25,6 +25,52 @@ from backend.app.utils.safe_path import PathTraversalError, safe_join_under logger = logging.getLogger(__name__) +# Bed temperature is not one key in a BambuStudio project. Every plate type has +# its own per-filament array, and the plate actually fitted is named separately +# in ``curr_bed_type`` -- so reading a bed temperature means picking the array +# the plate points at. Keys and mapping are BambuStudio's own +# ``get_bed_temp_1st_layer_key`` / ``get_bed_temp_key`` (PrintConfig.hpp), and +# the plate names are the ``curr_bed_type`` enum values (PrintConfig.cpp). +# First-layer temperature first: that is what the printer heats to before the +# print starts, which is what preheat is trying to reach. +# +# ``Default Plate`` is deliberately absent -- BambuStudio maps it to no key at +# all, so there is nothing to read and guessing a plate would invent a bed +# temperature the slice never specified. +_BED_TEMP_KEYS: dict[str, tuple[str, str]] = { + "Cool Plate": ("cool_plate_temp_initial_layer", "cool_plate_temp"), + "Engineering Plate": ("eng_plate_temp_initial_layer", "eng_plate_temp"), + "High Temp Plate": ("hot_plate_temp_initial_layer", "hot_plate_temp"), + "Textured PEI Plate": ("textured_plate_temp_initial_layer", "textured_plate_temp"), + "Supertack Plate": ("supertack_plate_temp_initial_layer", "supertack_plate_temp"), +} + +# Fallback for a config that names no plate: the Orca/PrusaSlicer spelling, +# which is a single value rather than a per-plate array. +_GENERIC_BED_TEMP_KEYS = ("bed_temperature_initial_layer", "bed_temperature") + + +def _plate_temperature(val) -> int | None: + """Bed temperature from one plate-temperature entry, or None. + + The plate arrays carry one entry per filament in the project, and a 0 means + that filament cannot print on this plate. The bed only has one temperature, + so the print runs at the highest its filaments ask for -- taking entry 0 the + way the neighbouring scalar settings do would store a 0 for any project + whose first filament is not one this plate is heated for. + """ + values = val if isinstance(val, list) else [val] + temps = [] + for entry in values: + if isinstance(entry, bool) or not isinstance(entry, (int, float, str)): + continue + try: + temps.append(int(float(entry))) + except (TypeError, ValueError): + continue + return max(temps) if temps else None + + def _copy_and_fsync(src: Path, dst: Path, chunk_size: int = 1024 * 1024) -> None: """Copy src to dst with an explicit chunked read/write and fsync the dst. @@ -498,14 +544,21 @@ class ThreeMFParser: elif isinstance(val, (int, float, str)): self.metadata["nozzle_diameter"] = float(val) - # Bed temperature - first layer or regular - for key in ["bed_temperature_initial_layer", "bed_temperature"]: - if key in data: - val = data[key] - if isinstance(val, list) and val: - self.metadata["bed_temperature"] = int(float(val[0])) - elif isinstance(val, (int, float, str)): - self.metadata["bed_temperature"] = int(float(val)) + # Bed temperature, for the plate this project is sliced for. This + # used to look for `bed_temperature` alone, a key BambuStudio does + # not write -- so every archive from a Bambu slice stored NULL, and + # preheat fell back to a configured bed temperature on every job + # (#2989). Orca-exported 3MFs keep working through the generic keys. + bed_type = str(data.get("curr_bed_type") or "").strip() + for key in (*_BED_TEMP_KEYS.get(bed_type, ()), *_GENERIC_BED_TEMP_KEYS): + if key not in data: + continue + temperature = _plate_temperature(data[key]) + # A plate array of all zeros means no filament in the project + # prints on this plate, which is not a bed temperature -- keep + # looking rather than recording a 0 that reads as "cold bed". + if temperature: + self.metadata["bed_temperature"] = temperature break # Nozzle temperature diff --git a/backend/tests/unit/test_archive_bed_temperature_2989.py b/backend/tests/unit/test_archive_bed_temperature_2989.py new file mode 100644 index 000000000..73b63a874 --- /dev/null +++ b/backend/tests/unit/test_archive_bed_temperature_2989.py @@ -0,0 +1,164 @@ +"""BambuStudio does not write a ``bed_temperature`` key, so we never read one. + +The 3MF stores one bed-temperature array per plate type -- ``cool_plate_temp``, +``eng_plate_temp``, ``hot_plate_temp``, ``textured_plate_temp``, +``supertack_plate_temp`` -- and names the plate the project is sliced for in +``curr_bed_type``. ``bed_temperature`` is the Orca/PrusaSlicer spelling. The +extractor looked only for that spelling, so ``PrintArchive.bed_temperature`` was +NULL for every archive produced from a Bambu slice: measured at 0 of 455 real +3MFs on a live install, against 455 of 455 once the plate keys are read. + +That NULL is not cosmetic. Preheat reads ``archive.bed_temperature`` to decide +what to heat the bed to, and with nothing there it falls back to the configured +chamber-heating bed temperature (90 by default) on jobs that never wanted it -- +which is how #2989's PLA print came to sit behind a 90°C bed. + +Key names and the plate mapping are BambuStudio's own ``get_bed_temp_key`` / +``get_bed_temp_1st_layer_key`` (PrintConfig.hpp); the plate names are the +``curr_bed_type`` enum values (PrintConfig.cpp). +""" + +import pytest + +from backend.app.services.archive import ThreeMFParser + + +def _extract(config: dict) -> int | None: + parser = ThreeMFParser.__new__(ThreeMFParser) + parser.metadata = {} + parser._extract_print_settings(config) + return parser.metadata.get("bed_temperature") + + +class TestThePlateTheProjectIsSlicedFor: + @pytest.mark.parametrize( + "bed_type,key", + [ + ("Cool Plate", "cool_plate_temp"), + ("Engineering Plate", "eng_plate_temp"), + ("High Temp Plate", "hot_plate_temp"), + ("Textured PEI Plate", "textured_plate_temp"), + ("Supertack Plate", "supertack_plate_temp"), + ], + ) + def test_every_plate_type_reads_its_own_array(self, bed_type, key): + """All five plates BambuStudio can name, each with its own key.""" + assert _extract({"curr_bed_type": bed_type, key: ["65", "65"]}) == 65 + + def test_the_fitted_plate_wins_over_the_others(self): + """The real failure mode this replaces: reading whichever key happened + to be present. A Textured slice must not take the cool plate's 0.""" + config = { + "curr_bed_type": "Textured PEI Plate", + "cool_plate_temp": ["0", "0", "0"], + "eng_plate_temp": ["90", "90", "90"], + "hot_plate_temp": ["90", "90", "90"], + "textured_plate_temp": ["55", "55", "55"], + "supertack_plate_temp": ["0", "0", "0"], + } + assert _extract(config) == 55 + + def test_the_first_layer_value_is_preferred(self): + """It is what the printer heats to before the print starts, which is + the number preheat is trying to reach.""" + config = { + "curr_bed_type": "Textured PEI Plate", + "textured_plate_temp_initial_layer": ["65"], + "textured_plate_temp": ["60"], + } + assert _extract(config) == 65 + + def test_the_regular_value_when_there_is_no_first_layer_one(self): + config = {"curr_bed_type": "Textured PEI Plate", "textured_plate_temp": ["60"]} + assert _extract(config) == 60 + + +class TestThePerFilamentArray: + def test_the_highest_entry_wins(self): + """One bed, several filaments: the print runs at the highest its + filaments ask for.""" + config = {"curr_bed_type": "High Temp Plate", "hot_plate_temp": ["55", "100", "90"]} + assert _extract(config) == 100 + + def test_a_leading_zero_does_not_win(self): + """0 means that filament cannot print on this plate. Taking entry 0 -- + what the neighbouring scalar settings do -- would store a cold bed for + any project whose first filament is not one this plate is heated for.""" + config = {"curr_bed_type": "High Temp Plate", "hot_plate_temp": ["0", "90"]} + assert _extract(config) == 90 + + def test_a_scalar_rather_than_an_array(self): + assert _extract({"curr_bed_type": "Cool Plate", "cool_plate_temp": 35}) == 35 + + def test_numeric_strings_and_floats(self): + assert _extract({"curr_bed_type": "Cool Plate", "cool_plate_temp": ["35.0"]}) == 35 + + +class TestWhatItRefusesToInvent: + def test_an_all_zero_plate_array_is_not_a_bed_temperature(self): + """No filament in the project prints on this plate. Recording the 0 + would read downstream as a deliberate cold bed.""" + config = {"curr_bed_type": "Cool Plate", "cool_plate_temp": ["0", "0"]} + assert _extract(config) is None + + def test_a_plate_bambustudio_maps_to_no_key(self): + """``Default Plate`` has no temperature array of its own, and guessing + another plate's would invent a temperature the slice never specified.""" + config = {"curr_bed_type": "Default Plate", "hot_plate_temp": ["90"]} + assert _extract(config) is None + + def test_a_plate_name_we_do_not_know(self): + """A plate a future BambuStudio adds must not silently read another + plate's array.""" + config = {"curr_bed_type": "Cryo Plate", "textured_plate_temp": ["55"]} + assert _extract(config) is None + + def test_a_config_with_no_bed_temperature_at_all(self): + assert _extract({"curr_bed_type": "Textured PEI Plate", "layer_height": ["0.2"]}) is None + + def test_junk_entries_are_stepped_over(self): + config = {"curr_bed_type": "Cool Plate", "cool_plate_temp": [None, {}, "abc", "35"]} + assert _extract(config) == 35 + + +class TestOrcaExportsStillWork: + """The generic spelling is the fallback, not the primary -- Orca-exported + 3MFs and anything else Prusa-shaped keep parsing exactly as before.""" + + def test_the_generic_first_layer_key(self): + assert _extract({"bed_temperature_initial_layer": ["60"]}) == 60 + + def test_the_generic_key(self): + assert _extract({"bed_temperature": 60}) == 60 + + def test_the_plate_array_is_preferred_when_both_are_present(self): + config = { + "curr_bed_type": "Textured PEI Plate", + "textured_plate_temp": ["55"], + "bed_temperature": ["90"], + } + assert _extract(config) == 55 + + +class TestTheNeighbouringSettingsAreUntouched: + """The same method extracts three other values; none of them changed.""" + + def test_nozzle_temperature_layer_height_and_diameter(self): + parser = ThreeMFParser.__new__(ThreeMFParser) + parser.metadata = {} + parser._extract_print_settings( + { + "curr_bed_type": "Textured PEI Plate", + "textured_plate_temp": ["55"], + "nozzle_temperature_initial_layer": ["220", "220"], + "layer_height": ["0.2"], + "nozzle_diameter": ["0.4"], + } + ) + assert parser.metadata == { + "bed_type": "Textured PEI Plate", + "layer_height": 0.2, + "nozzle_diameter": 0.4, + "bed_temperature": 55, + "nozzle_temperature": 220, + }