فهرست منبع

fix(drying): dry a composite spool as its base material (issue #3067)

The reporter's AMS-HT would not auto-dry PA6-CF, and drying the same spool by
hand worked. The scheduler reduced a tray to a preset key by splitting on spaces
only, so "PA6-CF" stayed "PA6-CF", matched none of the eight rows the preset
table has, and the tray was read as holding nothing worth drying. The AMS was
then passed over on every scheduler sweep, silently, because every caller reads
"no row" as "nothing to do for this tray".

It was never only nylon. Of the 41 types a printer can report, 33 had no row
under that rule, and 20 of those have a base material sitting right there: every
-CF, -GF and -AERO variant of PLA, PETG, ABS, ASA, PC and PA.

Doing it by hand worked because the drying popover has resolved composites since
exact key first, so a row the user added for the exact type still wins, then the
suffix, then an alias map reading PA6, PA11, PA12, PAHT, PPA and Nylon as PA.

PPA is the one alias that is a judgement rather than a spelling. Polyphthalamide
is a distinct polymer, not a grade of nylon -- but it is an aromatic polyamide,
it takes up moisture the same way, and PA's row is the hottest the table has.

A material with no row and no alias is still skipped rather than dried at a
number nothing here can source. That is where this parts company with the
popover, which falls back to PLA because a dropdown has to show something.

The table is user-editable JSON, so a preset row can be present and empty. That
has always meant "skip this material" and still does: the temp and hours reads
fall back per field to 55C/12h, which would dry a PLA spool at 55 degrees.

Two more callers had the same line and move with it: per-filament humidity
thresholds, where the override set for a material never applied to that
material's composites, and the chamber preheat target, which had the suffix half
of this from #2902 but not the aliases.

The preheat test for that half reimplemented the lookup inline rather than
calling it, so it would have passed whatever the function did. It calls it now.
maziggy 1 روز پیش
والد
کامیت
4440738904

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **Auto-drying skipped every composite spool (#3067, reported by @TheUltimateC0der)** — the reporter's AMS-HT would not dry PA6-CF, and drying the same spool by hand worked. The scheduler reduced a tray to a preset key by splitting on spaces only, so "PA6-CF" stayed "PA6-CF", matched none of the eight rows the preset table has, and the tray was read as holding nothing worth drying. It was never only nylon: of the 41 types a printer can report, 33 had no row under that rule, and 20 of those — every -CF, -GF and -AERO variant of PLA, PETG, ABS, ASA, PC and PA — have a base material sitting right there. The drying popover has resolved these correctly since 1.2.5, which is exactly why doing it by hand worked; the scheduler now shares that rule, including the alias map that reads PA6, PA11, PA12, PAHT, PPA and Nylon as PA. A row you have added for the exact type still wins over the base material's, and a material whose base is not in the table either is still left alone rather than dried at a temperature nobody chose. The same lookup decides per-filament humidity thresholds and the chamber preheat target, so a composite now gets the override and the preheat that were set for its material too.
 - **An archive gave up on its 3MF for good after one slow transfer (#3063, reported by @dfrysinger)** — the reporter's P1S had the file on its card and was serving it; the 19 MB transfer just did not finish inside the budget while the printer was also running its camera, its status messages and the job upload. Bambuddy wrote an empty archive and never looked again — and then downloaded that same file successfully three times in the next two minutes, throwing each copy away, because the only code that would have attached one had already run. Recovery for a fallback archive existed but was armed for exactly one give-up, the FTPS cool-off. It now also covers a transfer that ran out of time: Bambuddy comes back after one, four and ten minutes and fills the row in where an attempt lands. The two are told apart by what the printer said — a file that genuinely is not on the card is answered with 550, and that answer does not improve with waiting, so nothing is scheduled for it. Nor for a 3MF that downloaded fine but turned out to be another plate's, where retrying would put back exactly what was just discarded. The archives banner has wording for this case too, because the old text sent an owner whose card was working to switch on a setting that was already on; it names the Connection Timeout setting instead.
 - **Items Printed could not be set to 0 after a total plate failure (#3051, reported by @tdavis75)** — a jam ruined everything on the plate while the printer reported the job a success, so the honest count of usable parts was zero; the field refused to go below one. The manual quantity override is what the documentation points at for exactly this correction, and a project's completed-items count sums that column, so there was no way to tell a project that a job produced nothing. The floor was in the edit dialog alone — the API had always stored whatever it was given, which also meant a negative count was accepted and would have subtracted from the project totals. Zero is now typeable and the column is bounded at zero, and the Filament Trends widget counts a zeroed archive as no prints rather than silently reading the 0 as "unset" and charging one.
 - **Bulk edit could not turn G-code injection on or off (#3058)** — every other per-item print option in the queue can be changed for a whole selection at once, but **Inject G-code** was only ever settable one item at a time, in the item's own edit dialog. The bulk dialog simply had no control for it, which on a queue of a few dozen jobs meant opening every single one to arm the start and end snippets an auto-print system needs — the thing bulk edit exists to avoid. It is now a tri-state next to **Auto power off after print**, unchanged by default like the rest, and it appears only once a G-code snippet has actually been saved for some printer model, matching the checkbox in the print dialog. Nothing changed on the server: the bulk endpoint has always accepted the field, so this was a missing control rather than a missing capability.

+ 94 - 13
backend/app/services/print_scheduler.py

@@ -6,6 +6,7 @@ import logging
 import time
 import uuid
 from collections import deque
+from collections.abc import Mapping
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
@@ -3772,6 +3773,61 @@ class PrintScheduler:
                 continue
         return out
 
+    # Materials whose AMS spelling differs from the key the tables above use.
+    # Bambu labels nylon "PA" while its own composites spell the family out, so
+    # PA6, PA11, PA12 and PAHT would otherwise miss a table with a perfectly
+    # good PA row (#3067).
+    #
+    # Mirrors DRYING_MATERIAL_ALIASES in frontend/src/utils/dryingPresets.ts.
+    # The drying popover has resolved these correctly since #2774 and the
+    # scheduler never did, which is exactly why #3067's reporter could dry a
+    # PA6-CF spool by hand while auto-drying skipped it every pass.
+    #
+    # PPA is here too. Polyphthalamide is a distinct polymer rather than a grade
+    # of nylon, so it is the one entry that is a judgement rather than a
+    # spelling -- but it is an aromatic polyamide, it absorbs moisture the same
+    # way, and PA's row is the hottest the table has. Drying it there is closer
+    # to right than not drying it at all, which is what it got before.
+    FILAMENT_KEY_ALIASES: dict[str, str] = {
+        "NYLON": "PA",
+        "PA6": "PA",
+        "PA11": "PA",
+        "PA12": "PA",
+        "PAHT": "PA",
+        "PPA": "PA",
+    }
+
+    @classmethod
+    def _resolve_filament_key(cls, tray_type: str | None, table: Mapping[str, object]) -> str | None:
+        """The key in *table* that answers for this tray's material, or None.
+
+        The printer reports the material in ``tray_type``, and it spells filled
+        and foamed variants out: PLA-CF, PETG-CF, ABS-GF, PLA-AERO, PA6-CF. The
+        tables here are keyed by base material, so matching the raw string alone
+        found a row for 8 of the 41 types a printer can report and skipped the
+        rest -- silently, because every caller reads "no row" as "nothing to do
+        for this tray". Auto-drying therefore ignored every composite spool on
+        the install (#3067).
+
+        Exact match first, so a table the user has extended with a row of its
+        own -- ``PA6-CF`` at a temperature they picked -- still wins over the
+        base material's. Then the suffix is dropped, then the alias map above
+        answers for the polyamide spellings.
+
+        Returns None rather than a default: what to do with an unrecognised
+        material differs per caller, and only the caller knows whether "no row"
+        means skip the tray or fall back to a catch-all. Nothing here invents a
+        temperature for a material the table does not list.
+        """
+        raw = cls._normalize_filament_type(tray_type or "")
+        if not raw:
+            return None
+        for candidate in (raw, raw.split("-")[0]):
+            key = cls.FILAMENT_KEY_ALIASES.get(candidate, candidate)
+            if key in table:
+                return key
+        return None
+
     @staticmethod
     def resolve_humidity_threshold(trays: list[dict], thresholds: dict[str, int], fallback: int) -> int:
         """Resolve the effective humidity threshold for an AMS unit (#1605).
@@ -3791,8 +3847,10 @@ class PrintScheduler:
             tray_type = str(tray.get("tray_type") or "").strip()
             if not tray_type:
                 continue
-            base_type = tray_type.split()[0].upper()
-            candidates.append(thresholds.get(base_type, default))
+            # A composite carries its base material's threshold when it has no
+            # row of its own, the same way it takes its drying preset (#3067).
+            key = PrintScheduler._resolve_filament_key(tray_type, thresholds)
+            candidates.append(thresholds[key] if key is not None else default)
         if not candidates:
             return default
         return min(candidates)
@@ -3815,9 +3873,17 @@ class PrintScheduler:
             tray_type = tray.get("tray_type", "")
             if not tray_type:
                 continue
-            # Normalize filament type for preset lookup (e.g., "PLA Basic" -> "PLA")
-            base_type = tray_type.split()[0].upper()
-            preset = presets.get(base_type)
+            # "PLA Basic" -> PLA, and "PA6-CF" -> PA rather than nothing at all,
+            # which is what stopped auto-drying on every composite spool (#3067).
+            base_type = self._resolve_filament_key(tray_type, presets)
+            if base_type is None:
+                continue
+            # The table is user-editable JSON with no per-row validation, so a
+            # row can be present and empty. That has always meant "skip this
+            # material", and it has to keep meaning it: the reads below fall
+            # back to 55C/12h per missing field, which is a temperature nobody
+            # chose and would deform a PLA spool.
+            preset = presets[base_type]
             if not preset:
                 continue
 
@@ -4617,8 +4683,18 @@ class PrintScheduler:
         """Reduce the printer's tray_type to a preset-lookup key. Mirrors the
         existing drying-preset normalisation (split-at-space, upper-case) so
         the two maps share vocabulary — "PLA Basic" → "PLA", "PA-CF" stays
-        "PA-CF" (no space to split on)."""
-        return tray_type.split()[0].upper() if tray_type else ""
+        "PA-CF" (no space to split on).
+
+        This is the first stage of ``_resolve_filament_key``, which goes on to
+        drop the suffix and consult the alias map; on its own it only decides
+        what the tray is called, not which row answers for it.
+
+        Indexing the split rather than testing the input: a tray_type of spaces
+        is truthy and splits to nothing, so the old ``if tray_type`` guard let
+        it through to an IndexError.
+        """
+        words = (tray_type or "").split()
+        return words[0].upper() if words else ""
 
     def _target_for_tray_type(self, tray_type: str | None, targets: dict[str, int]) -> int:
         """Per-filament chamber target for one tray's reported type, or 0 when
@@ -4629,14 +4705,19 @@ class PrintScheduler:
         not the 0 an unknown type falls to. The specific type is still tried
         first, so PETG-CF and PA-CF keep the hotter rows they are listed with
         (#2902).
+
+        That lookup is now the shared one, which adds the polyamide aliases on
+        top of the suffix it already dropped -- so PA6-CF reaches PA's row here
+        too, rather than the catch-all it was landing on (#3067).
         """
-        normalised = self._normalize_filament_type(tray_type or "")
-        if not normalised:
+        if not self._normalize_filament_type(tray_type or ""):
             return 0
-        target = targets.get(normalised)
-        if target is None:
-            target = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
-        return target
+        key = self._resolve_filament_key(tray_type, targets)
+        if key is not None:
+            return targets[key]
+        # A type the map does not list at all, which is not the same as a tray
+        # with nothing in it -- that already returned 0 above.
+        return targets.get("DEFAULT", 0)
 
     def _derive_chamber_target(
         self,

+ 157 - 0
backend/tests/unit/test_scheduler_auto_drying.py

@@ -101,6 +101,143 @@ class TestConservativeDryingParams:
         assert result == (50, 6, "PLA")
 
 
+class TestCompositesResolveToTheirBaseMaterial:
+    """#3067: a composite spool was skipped by auto-drying entirely.
+
+    The preset key came from ``tray_type.split()[0].upper()``, which splits on
+    spaces only -- so "PA6-CF" stayed "PA6-CF", found no row in an 8-key table,
+    and the tray contributed nothing. Every caller reads "no row" as "nothing to
+    dry here", so the AMS was passed over on every scheduler pass, silently.
+
+    It was never only PA. Of the 41 types a printer can report, 33 had no row
+    under that rule and 20 of them have a base material sitting right there:
+    every -CF, -GF and -AERO variant of PLA, PETG, ABS, ASA, PC and PA.
+
+    The reporter could still dry the same spool by hand, because the drying
+    popover has resolved composites since #2774 -- these tests pin the two ends
+    to the same answer.
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.parametrize(
+        ("tray_type", "expected_key"),
+        [
+            # The reported spool, and the rest of the polyamide spellings. Bambu
+            # labels nylon "PA" and spells its own composites out, so none of
+            # these match a PA row without the alias map.
+            ("PA6-CF", "PA"),
+            ("PA6-GF", "PA"),
+            ("PA12-CF", "PA"),
+            ("PAHT-CF", "PA"),
+            ("PA-CF", "PA"),
+            ("Nylon", "PA"),
+            # Polyphthalamide is a distinct polymer, not a nylon grade, so this
+            # one is a judgement: an aromatic polyamide that takes up moisture
+            # the same way, dried on the hottest row the table has.
+            ("PPA-CF", "PA"),
+            ("PPA-GF", "PA"),
+            # ...and the variants of everything else, which were equally skipped.
+            ("PLA-CF", "PLA"),
+            ("PLA-GF", "PLA"),
+            ("PLA-AERO", "PLA"),
+            ("PLA-S", "PLA"),
+            ("PETG-CF", "PETG"),
+            ("ABS-GF", "ABS"),
+            ("ASA-CF", "ASA"),
+            ("ASA-AERO", "ASA"),
+            ("PC-CF", "PC"),
+            # Already worked, and must keep working.
+            ("PLA", "PLA"),
+            ("PLA Basic", "PLA"),
+            ("TPU for AMS", "TPU"),
+        ],
+    )
+    def test_the_tray_reaches_its_base_materials_preset(self, scheduler, tray_type, expected_key):
+        result = scheduler._get_conservative_drying_params(
+            [{"tray_type": tray_type}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
+        )
+        assert result is not None, f"{tray_type} is still skipped by auto-drying"
+        assert result[2] == expected_key
+        assert result[0] == PrintScheduler.DEFAULT_DRYING_PRESETS[expected_key]["n3s"]
+
+    def test_the_reported_spool_gets_nylons_temperature(self, scheduler):
+        """The whole point of resolving it rather than defaulting: PA6-CF wants
+        PA's 85C on an AMS-HT. Landing on PLA's 45 would run a cycle that dries
+        nothing, which is worse than the skip it replaces -- it looks like it
+        worked."""
+        result = scheduler._get_conservative_drying_params(
+            [{"tray_type": "PA6-CF"}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
+        )
+        assert result == (85, 12, "PA")
+
+    @pytest.mark.parametrize("tray_type", ["PPS-CF", "PET-CF", "PEEK", "PP", "PE", "wildly unknown"])
+    def test_a_material_with_no_base_row_is_still_skipped(self, scheduler, tray_type):
+        """Nothing here invents a drying profile. A material with no row and no
+        alias keeps the behaviour it has today rather than being dried at a
+        number nobody chose.
+
+        This is deliberately where the backend parts company with the drying
+        popover, which falls back to PLA because a dropdown has to show
+        something. A scheduler does not.
+        """
+        result = scheduler._get_conservative_drying_params(
+            [{"tray_type": tray_type}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
+        )
+        assert result is None
+
+    def test_a_user_row_for_the_exact_type_wins_over_the_base(self, scheduler):
+        """Someone who has added PA6-CF to their own table meant it."""
+        custom = {
+            **PrintScheduler.DEFAULT_DRYING_PRESETS,
+            "PA6-CF": {"n3f": 70, "n3s": 90, "n3f_hours": 10, "n3s_hours": 10},
+        }
+        result = scheduler._get_conservative_drying_params([{"tray_type": "PA6-CF"}], "n3s", custom)
+        assert result == (90, 10, "PA6-CF")
+
+    def test_a_mixed_load_still_takes_the_coolest_row(self, scheduler):
+        """Resolving more types must not disturb the conservative choice: a
+        PA6-CF spool sharing the unit with PLA still gets PLA's 45C, because
+        85 would deform the PLA."""
+        result = scheduler._get_conservative_drying_params(
+            [{"tray_type": "PA6-CF"}, {"tray_type": "PLA"}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
+        )
+        assert result[0] == 45
+
+    def test_an_empty_preset_row_still_means_skip(self, scheduler):
+        """The table is user-editable JSON and nothing validates a row, so one
+        can be present and empty. Resolving the key is not the same as having a
+        preset: the temp/hours reads each fall back to 55C/12h, which would dry
+        a PLA spool at 55 degrees because somebody left a row blank."""
+        custom = {**PrintScheduler.DEFAULT_DRYING_PRESETS, "PLA": {}}
+        assert scheduler._get_conservative_drying_params([{"tray_type": "PLA"}], "n3s", custom) is None
+        # And it does not quietly fall through to some other row either.
+        assert scheduler._get_conservative_drying_params([{"tray_type": "PLA-CF"}], "n3s", custom) is None
+
+    def test_a_zero_valued_row_is_a_row(self, scheduler):
+        """The resolver tests key presence, not truthiness. It is shared with the
+        chamber-preheat map, where 0 is the correct target for PLA, PETG, TPU and
+        PVA -- reading those as "no row" would send every one of them to the
+        catch-all."""
+        targets = PrintScheduler._bundled_preheat_targets()
+        assert targets["PLA"] == 0
+        assert PrintScheduler._resolve_filament_key("PLA", targets) == "PLA"
+        assert scheduler._target_for_tray_type("PLA", targets) == 0
+        assert scheduler._target_for_tray_type("PLA-CF", targets) == 0
+
+    def test_a_whitespace_only_tray_type_is_not_a_material(self, scheduler):
+        """Truthy, and splits to nothing. The old normaliser indexed the split
+        after testing the string, so this raised IndexError rather than reading
+        as an empty tray."""
+        assert PrintScheduler._normalize_filament_type("   ") == ""
+        result = scheduler._get_conservative_drying_params(
+            [{"tray_type": "   "}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
+        )
+        assert result is None
+
+
 class TestDryingPresets:
     """Test _get_drying_presets — loads user presets from DB or falls back to defaults."""
 
@@ -1068,6 +1205,26 @@ class TestResolveHumidityThreshold:
         )
         assert result == 50
 
+    def test_a_composite_takes_its_base_materials_threshold(self):
+        """Same lookup, same gap (#3067): a PA6-CF spool read as an unknown type
+        and took the default, so the override the user set for nylon -- the
+        material most worth a low threshold -- never applied to the spool they
+        set it for."""
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{"tray_type": "PA6-CF"}],
+            {"default": 60, "PA": 20},
+            60,
+        )
+        assert result == 20
+
+    def test_a_composite_with_its_own_threshold_row_keeps_it(self):
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{"tray_type": "PETG-CF"}],
+            {"default": 60, "PETG": 55, "PETG-CF": 40},
+            60,
+        )
+        assert result == 40
+
     def test_mixed_load_picks_lowest(self):
         """Mixed PLA (60) + Nylon (20) → most restrictive = 20."""
         result = PrintScheduler.resolve_humidity_threshold(

+ 19 - 6
backend/tests/unit/test_scheduler_preheat.py

@@ -751,14 +751,15 @@ async def test_a_filled_variant_preheats_like_its_base_material(monkeypatch):
     from backend.app.services.print_scheduler import PrintScheduler
 
     s = PrintScheduler()
-    targets = PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS
+    # The map as every read of it sees it: upper-cased, so "DEFAULT" is the
+    # catch-all key rather than the lowercase one the Settings editor writes.
+    targets = PrintScheduler._bundled_preheat_targets()
 
+    # Deliberately the production method rather than a copy of its rule. This
+    # test used to reimplement the lookup inline, which meant it went on passing
+    # whatever _target_for_tray_type did (#3067).
     def target_for(tray_type: str) -> int:
-        normalised = s._normalize_filament_type(tray_type)
-        value = targets.get(normalised)
-        if value is None:
-            value = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
-        return value
+        return s._target_for_tray_type(tray_type, targets)
 
     assert target_for("ASA-GF") == targets["ASA"]
     assert target_for("ASA-AERO") == targets["ASA"]
@@ -768,3 +769,15 @@ async def test_a_filled_variant_preheats_like_its_base_material(monkeypatch):
     assert target_for("PA-CF") == 55
     # And a plain type is untouched.
     assert target_for("PLA") == 0
+    # The polyamide spellings reach PA's row too, now that this shares the
+    # drying lookup's alias map (#3067). Stripping the suffix alone left PA6
+    # and PAHT on the catch-all, so those prints preheated to nothing.
+    assert target_for("PA6-CF") == targets["PA"]
+    assert target_for("PA12-CF") == targets["PA"]
+    assert target_for("PAHT-CF") == targets["PA"]
+    assert target_for("Nylon") == targets["PA"]
+    # An empty tray is not an unknown material: it reports no type at all and
+    # contributes no chamber target, where an unrecognised one takes the
+    # catch-all.
+    assert target_for("") == 0
+    assert target_for("   ") == 0

+ 11 - 0
frontend/src/__tests__/utils/dryingPresets.test.ts

@@ -67,9 +67,20 @@ describe('resolveDryingPresetKey', () => {
   });
 
   it('recognises the polyamide family under its own spellings', () => {
+    // The grades have to be listed: "PA12" shares no prefix rule with "PA", so
+    // dropping the suffix alone leaves it unrecognised. #3067 reported PA6 and
+    // named PA11 and PA12 alongside it.
     expect(resolveDryingPresetKey('PA6-CF', PRESETS)).toBe('PA');
+    expect(resolveDryingPresetKey('PA6-GF', PRESETS)).toBe('PA');
+    expect(resolveDryingPresetKey('PA11-CF', PRESETS)).toBe('PA');
+    expect(resolveDryingPresetKey('PA12-CF', PRESETS)).toBe('PA');
     expect(resolveDryingPresetKey('PAHT-CF', PRESETS)).toBe('PA');
     expect(resolveDryingPresetKey('Nylon', PRESETS)).toBe('PA');
+    // Polyphthalamide is a distinct polymer rather than a nylon grade, so this
+    // one is a judgement: an aromatic polyamide that takes up moisture the same
+    // way, dried on the hottest row the table has.
+    expect(resolveDryingPresetKey('PPA-CF', PRESETS)).toBe('PA');
+    expect(resolveDryingPresetKey('PPA-GF', PRESETS)).toBe('PA');
   });
 
   it('falls back to the coolest row for an unknown material', () => {

+ 8 - 0
frontend/src/utils/dryingPresets.ts

@@ -8,10 +8,18 @@ export type DryingPreset = { n3f: number; n3s: number; n3f_hours: number; n3s_ho
 // Materials whose AMS spelling differs from the preset table's key. Bambu
 // labels nylon "PA" while its own composites spell the family out, so PA6 and
 // PAHT would otherwise miss a table that has a perfectly good PA row.
+//
+// Kept in step with FILAMENT_KEY_ALIASES in backend/app/services/print_scheduler.py,
+// which the auto-drying scheduler reads. The two disagreeing is what #3067 was:
+// this popover dried a PA6-CF spool on request while the scheduler passed over
+// the same AMS on every sweep.
 const DRYING_MATERIAL_ALIASES: Record<string, string> = {
   'NYLON': 'PA',
   'PA6': 'PA',
+  'PA11': 'PA',
+  'PA12': 'PA',
   'PAHT': 'PA',
+  'PPA': 'PA',
 };
 
 /**

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-BMoCmvVS.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CBjd7PRB.js"></script>
+    <script type="module" crossorigin src="/assets/index-BMoCmvVS.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است