Przeglądaj źródła

Stop the drying badge inventing a temperature on a uniform AMS (#2759)

The follow-up to the same report: a second AMS 2 Pro, no aux power,
loaded entirely with PLA and drying at the 45C the reporter picked,
showed 45C and then switched to 55C.

Bambu never echoes back a cycle's filament or temperature, so both come
from the target cached when the command went out, and the fallback for
a missing cache reads the loaded trays. The first pass narrowed that
fallback to units whose spools agree on a filament, which fixed the
mixed-unit case in the original report but left the uniform case
answering with the spools' RFID-recommended drying_temp -- 55C here.
Agreement across slots is evidence of what is being dried, because the
dryer heats all of them. It is no evidence of the temperature, which is
picked freely in the popover, so the recommendation was never more than
a guess wearing the same confident "PLA @ 55C" as a known target.

uniform_tray_drying_hint therefore becomes uniform_tray_filament_hint
and returns the filament alone. The badge names a temperature only when
we sent it, and otherwise shows the filament and the countdown.

Both status builders also stopped filling the two fields independently.
Entering the fallback when either was missing let a cached filament pair
with a guessed temperature and render as though both were known; the
temperature now simply has no fallback to reach.

The badge required both fields before rendering anything, so dropping
the temperature would have blanked it rather than shortening it -- the
frontend now renders each on its own terms. No new translation key: the
filament type is a passthrough.

This changes what is shown when the cached target is missing, not why
it goes missing. If the reporter was on the fixed build, the falling-
edge gate is still letting a zero through on an unpowered unit, which
needs a log covering the start of the cycle.
maziggy 1 miesiąc temu
rodzic
commit
fce7ea0200

Plik diff jest za duży
+ 0 - 1
CHANGELOG.md


+ 7 - 13
backend/app/api/routes/printers.py

@@ -62,7 +62,7 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_chamber_temp,
     supports_drying,
     supports_drying,
     supports_drying_while_printing,
     supports_drying_while_printing,
-    uniform_tray_drying_hint,
+    uniform_tray_filament_hint,
 )
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.http import build_content_disposition
@@ -578,18 +578,12 @@ async def get_printer_status(
                     dry_target_temp = None
                     dry_target_temp = None
             if target_fil_val:
             if target_fil_val:
                 dry_filament = str(target_fil_val)
                 dry_filament = str(target_fil_val)
-            # Fallback: derive from the loaded trays when there is no cached
-            # target (drying started in a previous backend session, or the
-            # cache wasn't seeded), and only when they agree on a filament
-            # type. See uniform_tray_drying_hint.
-            if dry_target_temp is None or not dry_filament:
-                hint_filament, hint_temp = uniform_tray_drying_hint(
-                    [(tray.tray_type or "", tray.drying_temp) for tray in trays]
-                )
-                if not dry_filament:
-                    dry_filament = hint_filament
-                if dry_target_temp is None:
-                    dry_target_temp = hint_temp
+            # Fallback: name the filament from the loaded trays when there is no
+            # cached target (drying started in a previous backend session, or
+            # the cache wasn't seeded), and only when they agree. The
+            # temperature has no fallback — see uniform_tray_filament_hint.
+            if not dry_filament:
+                dry_filament = uniform_tray_filament_hint([tray.tray_type or "" for tray in trays])
 
 
             ams_units.append(
             ams_units.append(
                 AMSUnit(
                 AMSUnit(

+ 26 - 30
backend/app/services/printer_manager.py

@@ -280,8 +280,8 @@ def display_temperatures(temperatures: dict | None, model: str | None) -> dict[s
     return out
     return out
 
 
 
 
-def uniform_tray_drying_hint(loaded_trays: list[tuple[str, object]]) -> tuple[str | None, int | None]:
-    """Guess an active cycle's filament + target temperature from the loaded trays.
+def uniform_tray_filament_hint(loaded_types: list[str]) -> str | None:
+    """Guess an active cycle's filament from the loaded trays.
 
 
     Bambu never echoes back which filament or temperature a drying cycle is
     Bambu never echoes back which filament or temperature a drying cycle is
     running, so the badge normally reads the target we cached when we sent the
     running, so the badge normally reads the target we cached when we sent the
@@ -291,30 +291,31 @@ def uniform_tray_drying_hint(loaded_trays: list[tuple[str, object]]) -> tuple[st
     It answers only when every loaded tray holds the same filament type. On a
     It answers only when every loaded tray holds the same filament type. On a
     mixed unit the first tray is evidence of nothing: an AMS holding two PETG
     mixed unit the first tray is evidence of nothing: an AMS holding two PETG
     and two PLA spools, drying PLA at the 45°C the user picked, was labelled
     and two PLA spools, drying PLA at the 45°C the user picked, was labelled
-    "PETG @ 65°C" purely because slot 1 happened to be PETG (#2759). Saying
-    nothing and letting the badge show just the countdown beats stating a
-    temperature the cycle isn't using.
+    "PETG @ 65°C" purely because slot 1 happened to be PETG (#2759).
+
+    Deliberately no temperature. The RFID-recommended ``drying_temp`` used to be
+    returned alongside a uniform filament, which narrowed #2759 to units whose
+    spools disagree but left the uniform case stating a temperature just as
+    invented: a unit loaded entirely with PLA, drying at the 45°C the user
+    picked, read "PLA @ 55°C" the moment the cached target went missing. The
+    filament type is real evidence — every spool in the unit agrees on it, and
+    the dryer heats all of them — but the temperature is a free choice in the
+    popover, so a recommendation is never evidence of what is running. The badge
+    shows the filament and the countdown, and names a temperature only when we
+    actually sent it.
 
 
     Args:
     Args:
-        loaded_trays: ``(tray_type, drying_temp)`` for each tray, in slot order.
-            Empty slots (falsy tray_type) are ignored. ``drying_temp`` is the
-            RFID-recommended value and may be None or unparseable.
+        loaded_types: ``tray_type`` for each tray, in slot order. Empty slots
+            (falsy) are ignored.
 
 
     Returns:
     Returns:
-        ``(filament, temp)``, either of which may be None.
+        The shared filament type, or None if the loaded trays disagree or the
+        unit is empty.
     """
     """
-    types = {str(tray_type) for tray_type, _ in loaded_trays if tray_type}
+    types = {str(tray_type) for tray_type in loaded_types if tray_type}
     if len(types) != 1:
     if len(types) != 1:
-        return None, None
-    filament = next(iter(types))
-    for tray_type, drying_temp in loaded_trays:
-        if not tray_type or not drying_temp:
-            continue
-        try:
-            return filament, int(drying_temp)
-        except (TypeError, ValueError):
-            continue
-    return filament, None
+        return None
+    return next(iter(types))
 
 
 
 
 def supports_drying(model: str | None, firmware: str | None) -> bool:
 def supports_drying(model: str | None, firmware: str | None) -> bool:
@@ -1333,8 +1334,9 @@ def printer_state_to_dict(
             # per-tick AMS push, so prefer the cached target from the last
             # per-tick AMS push, so prefer the cached target from the last
             # ``send_drying_command``. When we have no record (drying
             # ``send_drying_command``. When we have no record (drying
             # started in a previous backend lifetime, or the cache was
             # started in a previous backend lifetime, or the cache was
-            # never seeded), fall back to the loaded trays — but only when
-            # they agree on a filament type. See uniform_tray_drying_hint.
+            # never seeded), the loaded trays can still name the filament
+            # if they agree — but never the temperature, which only the
+            # cache knows. See uniform_tray_filament_hint.
             ams_id_int = int(ams_data.get("id", 0))
             ams_id_int = int(ams_data.get("id", 0))
             target = (drying_targets or {}).get(ams_id_int)
             target = (drying_targets or {}).get(ams_id_int)
             dry_target_temp: int | None = None
             dry_target_temp: int | None = None
@@ -1349,14 +1351,8 @@ def printer_state_to_dict(
                         dry_target_temp = None
                         dry_target_temp = None
                 if fil_val:
                 if fil_val:
                     dry_filament = str(fil_val)
                     dry_filament = str(fil_val)
-            if dry_target_temp is None or not dry_filament:
-                hint_filament, hint_temp = uniform_tray_drying_hint(
-                    [(tray.get("tray_type") or "", tray.get("drying_temp")) for tray in trays]
-                )
-                if not dry_filament:
-                    dry_filament = hint_filament
-                if dry_target_temp is None:
-                    dry_target_temp = hint_temp
+            if not dry_filament:
+                dry_filament = uniform_tray_filament_hint([tray.get("tray_type") or "" for tray in trays])
 
 
             ams_units.append(
             ams_units.append(
                 {
                 {

+ 32 - 13
backend/tests/unit/services/test_printer_manager.py

@@ -1378,9 +1378,10 @@ class TestDryingTargetExposure:
         assert result["ams"][0]["dry_filament"] == "PETG"
         assert result["ams"][0]["dry_filament"] == "PETG"
         assert result["ams"][0]["dry_target_temp"] == 65
         assert result["ams"][0]["dry_target_temp"] == 65
 
 
-    def test_falls_back_to_loaded_tray_when_no_cache(self):
-        """No cached target → derive from the loaded trays' tray_type +
-        RFID-recommended drying_temp when they agree on a filament."""
+    def test_falls_back_to_loaded_tray_filament_when_no_cache(self):
+        """No cached target → name the filament from the loaded trays when they
+        agree on a type. The temperature stays unknown: only the cache records
+        what we actually sent."""
         state = self._state_with_ams(
         state = self._state_with_ams(
             {
             {
                 "id": 0,
                 "id": 0,
@@ -1392,7 +1393,7 @@ class TestDryingTargetExposure:
         )
         )
         result = printer_state_to_dict(state, drying_targets=None)
         result = printer_state_to_dict(state, drying_targets=None)
         assert result["ams"][0]["dry_filament"] == "ABS"
         assert result["ams"][0]["dry_filament"] == "ABS"
-        assert result["ams"][0]["dry_target_temp"] == 70
+        assert result["ams"][0]["dry_target_temp"] is None
 
 
     def test_returns_none_when_no_cache_and_empty_trays(self):
     def test_returns_none_when_no_cache_and_empty_trays(self):
         """No cache + no loaded tray with tray_type → both fields are None."""
         """No cache + no loaded tray with tray_type → both fields are None."""
@@ -1443,8 +1444,8 @@ class TestDryingTargetExposure:
         assert result["ams"][0]["dry_target_temp"] is None
         assert result["ams"][0]["dry_target_temp"] is None
 
 
     def test_fallback_survives_multiple_trays_of_one_type(self):
     def test_fallback_survives_multiple_trays_of_one_type(self):
-        """Agreement across slots is still evidence — a unit loaded entirely
-        with PLA keeps the fallback the mixed case gives up."""
+        """Agreement across slots is still evidence of the filament — a unit
+        loaded entirely with PLA keeps the name the mixed case gives up."""
         state = self._state_with_ams(
         state = self._state_with_ams(
             {
             {
                 "id": 0,
                 "id": 0,
@@ -1458,24 +1459,42 @@ class TestDryingTargetExposure:
         )
         )
         result = printer_state_to_dict(state, drying_targets={})
         result = printer_state_to_dict(state, drying_targets={})
         assert result["ams"][0]["dry_filament"] == "PLA"
         assert result["ams"][0]["dry_filament"] == "PLA"
-        assert result["ams"][0]["dry_target_temp"] == 45
 
 
-    def test_fallback_takes_temp_from_a_later_tray_when_slot_one_has_none(self):
-        """Only Bambu spools carry an RFID drying_temp. A third-party spool in
-        slot 1 alongside a genuine one of the same type should not cost us the
-        temperature."""
+    def test_uniform_unit_never_invents_a_temperature(self):
+        """#2759 follow-up — the reporter's second AMS held only PLA and was
+        drying at the 45°C they picked, but with no cached target the badge
+        answered with the RFID recommendation and read "PLA @ 55°C". Every
+        spool agreeing tells us the filament; it tells us nothing about a
+        temperature the user chose freely in the popover."""
         state = self._state_with_ams(
         state = self._state_with_ams(
             {
             {
                 "id": 0,
                 "id": 0,
                 "dry_time": 719,
                 "dry_time": 719,
                 "tray": [
                 "tray": [
-                    {"id": 0, "tray_type": "PLA", "state": 11},
-                    {"id": 1, "tray_type": "PLA", "drying_temp": 45, "state": 11},
+                    {"id": 0, "tray_type": "PLA", "drying_temp": 55, "state": 11},
+                    {"id": 1, "tray_type": "PLA", "drying_temp": 55, "state": 11},
                 ],
                 ],
             }
             }
         )
         )
         result = printer_state_to_dict(state, drying_targets={})
         result = printer_state_to_dict(state, drying_targets={})
         assert result["ams"][0]["dry_filament"] == "PLA"
         assert result["ams"][0]["dry_filament"] == "PLA"
+        assert result["ams"][0]["dry_target_temp"] is None
+
+    def test_cached_temp_survives_a_unit_whose_trays_disagree(self):
+        """The cache is authoritative for both fields. A mixed unit costs us the
+        filament fallback but must not touch a target we actually sent."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 719,
+                "tray": [
+                    {"id": 0, "tray_type": "PETG", "drying_temp": 65, "state": 11},
+                    {"id": 1, "tray_type": "PLA", "drying_temp": 55, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={0: {"filament": "PLA", "temp": 45}})
+        assert result["ams"][0]["dry_filament"] == "PLA"
         assert result["ams"][0]["dry_target_temp"] == 45
         assert result["ams"][0]["dry_target_temp"] == 45
 
 
 
 

+ 137 - 0
frontend/src/__tests__/pages/PrintersPageDryingBadge.test.tsx

@@ -0,0 +1,137 @@
+/**
+ * The active-cycle badge on the AMS card (#2759).
+ *
+ * Bambu never echoes back which filament or temperature a drying cycle is
+ * running, so the backend hands us two independent fields: `dry_filament`,
+ * which it can also infer from a uniformly loaded unit, and `dry_target_temp`,
+ * which it only knows from the target it cached when sending the command. The
+ * temperature can therefore go missing while the filament survives, and the
+ * badge has to render that pairing rather than dropping both.
+ *
+ * The reporter's second AMS held only PLA and was drying at the 45°C they
+ * picked; with no cached target the badge previously showed the RFID
+ * recommendation and read "PLA @ 55°C".
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const baseTray = {
+  tray_color: 'FF0000FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Basic',
+  tray_id_name: 'A00-R0',
+  tray_info_idx: 'GFA00',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: 55,
+  drying_time: 8,
+  state: 3,
+};
+
+/** An AMS 2 Pro twelve hours into a cycle, with the badge fields under test. */
+function makeStatus(target: { dry_filament: string | null; dry_target_temp: number | null }) {
+  return {
+    connected: true,
+    state: 'IDLE',
+    progress: 0,
+    layer_num: 0,
+    total_layers: 0,
+    temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+    remaining_time: 0,
+    filename: null,
+    wifi_signal: -29,
+    speed_level: 2,
+    supports_drying: true,
+    drying_screen_only: false,
+    vt_tray: [],
+    ams: [
+      {
+        id: 0,
+        humidity: 30,
+        temp: 33,
+        is_ams_ht: false,
+        serial_number: 'AMS00',
+        sw_ver: '03.00.21.29',
+        dry_time: 719,
+        dry_status: 2,
+        dry_sub_status: 0,
+        dry_sf_reason: [],
+        module_type: 'n3f',
+        ...target,
+        tray: [0, 1, 2, 3].map((id) => ({ id, ...baseTray })),
+      },
+    ],
+  };
+}
+
+function renderWith(target: { dry_filament: string | null; dry_target_temp: number | null }) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(makeStatus(target))),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+  );
+  render(<PrintersPage />);
+}
+
+describe('PrintersPage — AMS drying badge (#2759)', () => {
+  beforeEach(() => {
+    server.use(http.get('/api/v1/queue/', () => HttpResponse.json([])));
+  });
+
+  it('names the filament and the temperature when the cycle target is known', async () => {
+    renderWith({ dry_filament: 'PLA', dry_target_temp: 45 });
+
+    await waitFor(() => {
+      expect(screen.getAllByText('PLA @ 45°C').length).toBeGreaterThan(0);
+    });
+  });
+
+  it('still names the filament when only the temperature is unknown', async () => {
+    renderWith({ dry_filament: 'PLA', dry_target_temp: null });
+
+    // The filament survives on its own — dropping it too would leave the badge
+    // showing a bare countdown for a cycle we can still describe.
+    await waitFor(() => {
+      expect(screen.getAllByText('PLA').length).toBeGreaterThan(0);
+    });
+    // And it must not fall back to the trays' RFID recommendation (55°C here),
+    // which is what the user's chosen 45°C was being overwritten with. Scoped
+    // to the badge's own "<filament> @ <temp>°C" shape — the card carries
+    // unrelated nozzle and bed readings in °C.
+    expect(screen.queryByText(/@ \d+°C/)).toBeNull();
+  });
+
+  it('shows the countdown alone when the unit gives no evidence at all', async () => {
+    renderWith({ dry_filament: null, dry_target_temp: null });
+
+    await waitFor(() => {
+      expect(screen.getAllByText(/11h 59m/).length).toBeGreaterThan(0);
+    });
+    expect(screen.queryByText(/@ \d+°C/)).toBeNull();
+  });
+});

+ 12 - 4
frontend/src/pages/PrintersPage.tsx

@@ -4911,9 +4911,15 @@ function PrinterCard({
                               <div className="flex items-center gap-2 rounded-lg bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]">
                               <div className="flex items-center gap-2 rounded-lg bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]">
                                 <Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />
                                 <Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />
                                 <span className="text-amber-700 dark:text-amber-400 font-medium">{t('printers.drying.active')}</span>
                                 <span className="text-amber-700 dark:text-amber-400 font-medium">{t('printers.drying.active')}</span>
-                                {ams.dry_filament && ams.dry_target_temp != null && (
+                                {/* The temperature is only ever known from the target we
+                                    cached when sending the command — the filament can also
+                                    be read off a uniformly loaded unit, so it can outlive
+                                    the temperature (#2759). */}
+                                {ams.dry_filament && (
                                   <span className="text-amber-700/80 dark:text-amber-300/70">
                                   <span className="text-amber-700/80 dark:text-amber-300/70">
-                                    {t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })}
+                                    {ams.dry_target_temp != null
+                                      ? t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })
+                                      : ams.dry_filament}
                                   </span>
                                   </span>
                                 )}
                                 )}
                                 <span className="text-amber-700/80 dark:text-amber-300/70">
                                 <span className="text-amber-700/80 dark:text-amber-300/70">
@@ -5453,9 +5459,11 @@ function PrinterCard({
                             {ams.dry_time > 0 && (
                             {ams.dry_time > 0 && (
                               <div className="flex items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-lg bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]">
                               <div className="flex items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-lg bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]">
                                 <Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />
                                 <Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />
-                                {ams.dry_filament && ams.dry_target_temp != null && (
+                                {ams.dry_filament && (
                                   <span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">
                                   <span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">
-                                    {t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })}
+                                    {ams.dry_target_temp != null
+                                      ? t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })
+                                      : ams.dry_filament}
                                   </span>
                                   </span>
                                 )}
                                 )}
                                 <span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">
                                 <span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">

Plik diff jest za duży
+ 0 - 0
static/assets/index-yKwaHTh1.js


+ 1 - 1
static/index.html

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

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików