Explorar o código

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.
maziggy hai 2 meses
pai
achega
fd61812d01

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
CHANGELOG.md


+ 36 - 1
backend/app/api/routes/printers.py

@@ -476,6 +476,11 @@ async def get_printer_status(
             except (ValueError, TypeError):
                 pass  # Skip K-profile entries with unparseable values
 
+    # Cached active-cycle drying params (filament + target temp) we sent
+    # last; Bambu doesn't echo them on the per-tick AMS push, so the badge
+    # needs the cache to render "<filament> @ <temp>°C".
+    drying_targets = printer_manager.get_drying_targets(printer_id) or {}
+
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         ams_exists = True
         for ams_data in raw_data["ams"]:
@@ -537,9 +542,37 @@ async def get_printer_status(
             # AMS-HT has 1 tray, regular AMS has 4 trays
             is_ams_ht = len(trays) == 1
 
+            ams_id_int = int(ams_data.get("id", 0))
+            target = drying_targets.get(ams_id_int) or {}
+            dry_target_temp: int | None = None
+            dry_filament: str | None = None
+            target_temp_val = target.get("temp")
+            target_fil_val = target.get("filament") or ""
+            if target_temp_val is not None:
+                try:
+                    dry_target_temp = int(target_temp_val)
+                except (TypeError, ValueError):
+                    dry_target_temp = None
+            if target_fil_val:
+                dry_filament = str(target_fil_val)
+            # Fallback: derive from first loaded tray when no cached target
+            # (drying started in a previous backend session, or cache wasn't
+            # seeded). Mirrors the popover seed heuristic.
+            if dry_target_temp is None or not dry_filament:
+                for tray in trays:
+                    if tray.tray_type:
+                        if not dry_filament:
+                            dry_filament = str(tray.tray_type)
+                        if dry_target_temp is None and tray.drying_temp:
+                            try:
+                                dry_target_temp = int(tray.drying_temp)
+                            except (TypeError, ValueError):
+                                pass
+                        break
+
             ams_units.append(
                 AMSUnit(
-                    id=ams_data.get("id", 0),
+                    id=ams_id_int,
                     humidity=humidity_value,
                     temp=ams_data.get("temp"),
                     is_ams_ht=is_ams_ht,
@@ -550,6 +583,8 @@ async def get_printer_status(
                     sw_ver=str(ams_data.get("sw_ver") or ""),
                     # Drying: dry_time > 0 means drying is active (minutes remaining)
                     dry_time=int(ams_data.get("dry_time") or 0),
+                    dry_target_temp=dry_target_temp,
+                    dry_filament=dry_filament,
                     module_type=str(ams_data.get("module_type") or ""),
                 )
             )

+ 12 - 2
backend/app/api/routes/websocket.py

@@ -97,7 +97,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
                 {
                     "type": "printer_status",
                     "printer_id": printer_id,
-                    "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                    "data": printer_state_to_dict(
+                        state,
+                        printer_id,
+                        printer_manager.get_model(printer_id),
+                        printer_manager.get_drying_targets(printer_id),
+                    ),
                 }
             )
 
@@ -129,7 +134,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
                             {
                                 "type": "printer_status",
                                 "printer_id": printer_id,
-                                "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                                "data": printer_state_to_dict(
+                                    state,
+                                    printer_id,
+                                    printer_manager.get_model(printer_id),
+                                    printer_manager.get_drying_targets(printer_id),
+                                ),
                             }
                         )
 

+ 12 - 2
backend/app/main.py

@@ -1358,7 +1358,12 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
     await ws_manager.send_printer_status(
         printer_id,
-        printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+        printer_state_to_dict(
+            state,
+            printer_id,
+            printer_manager.get_model(printer_id),
+            printer_manager.get_drying_targets(printer_id),
+        ),
     )
 
 
@@ -1393,7 +1398,12 @@ async def on_ams_change(printer_id: int, ams_data: list):
             logger.info("[Printer %s] Broadcasting AMS change via WebSocket", printer_id)
             await ws_manager.send_printer_status(
                 printer_id,
-                printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                printer_state_to_dict(
+                    state,
+                    printer_id,
+                    printer_manager.get_model(printer_id),
+                    printer_manager.get_drying_targets(printer_id),
+                ),
             )
     except Exception as e:
         logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e)

+ 2 - 0
backend/app/schemas/printer.py

@@ -186,6 +186,8 @@ class AMSUnit(BaseModel):
     dry_status: int = 0  # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
     dry_sub_status: int = 0  # 0=Off, 1=Heating, 2=Dehumidify
     dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)
+    dry_target_temp: int | None = None  # Active-cycle target °C (Bambu doesn't echo this)
+    dry_filament: str | None = None  # Active-cycle filament name we sent
     module_type: str = ""  # "ams", "n3f", "n3s"
 
 

+ 48 - 32
backend/app/services/bambu_mqtt.py

@@ -496,6 +496,11 @@ class BambuMQTTClient:
         # Per-AMS previous dry_time, used to detect the falling edge above.
         # Seeded lazily as we observe each AMS unit.
         self._previous_dry_times: dict[int, int] = {}
+        # Per-AMS active-cycle target params (filament + temp) we sent on the
+        # last start. Bambu does not echo these back in the per-tick AMS push
+        # — only the dry_time countdown — so we cache what we sent to drive
+        # the UI badge. Cleared on stop or on the dry_time falling edge to 0.
+        self._drying_targets: dict[int, dict[str, object]] = {}
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -2056,38 +2061,40 @@ class BambuMQTTClient:
 
         # Detect AMS drying-complete falling edge per-unit (#1349). When an
         # AMS's `dry_time` transitions from >0 to 0 the cycle just finished
-        # — fire the callback so smart-plug auto-off-after-drying can run.
-        # Works identically for queue-triggered, ambient, and manual drying
-        # because we observe the firmware-reported state, not our own intent.
-        if self.on_drying_complete:
-            for ams_unit in merged_ams:
-                try:
-                    ams_id = int(ams_unit.get("id", -1))
-                except (TypeError, ValueError):
-                    continue
-                if ams_id < 0:
-                    continue
-                # Only evaluate the edge when this update carries an explicit
-                # dry_time. An absent / unparseable value is NOT zero — treating
-                # it as 0 lets a tray-only partial fake a drying-complete edge
-                # (#1462). Skip without touching the remembered value so the
-                # next update that DOES carry dry_time sees the true previous.
-                raw_dry_time = ams_unit.get("dry_time")
-                if raw_dry_time is None:
-                    continue
-                try:
-                    current = int(raw_dry_time)
-                except (TypeError, ValueError):
-                    continue
-                previous = self._previous_dry_times.get(ams_id, 0)
-                self._previous_dry_times[ams_id] = current
-                if previous > 0 and current == 0:
-                    logger.info(
-                        "[%s] AMS %d drying complete (dry_time %d → 0)",
-                        self.serial_number,
-                        ams_id,
-                        previous,
-                    )
+        # — fire the callback so smart-plug auto-off-after-drying can run,
+        # and drop our cached target-cycle params so the badge stops claiming
+        # an active cycle. Works identically for queue-triggered, ambient,
+        # and manual drying because we observe the firmware-reported state.
+        for ams_unit in merged_ams:
+            try:
+                ams_id = int(ams_unit.get("id", -1))
+            except (TypeError, ValueError):
+                continue
+            if ams_id < 0:
+                continue
+            # Only evaluate the edge when this update carries an explicit
+            # dry_time. An absent / unparseable value is NOT zero — treating
+            # it as 0 lets a tray-only partial fake a drying-complete edge
+            # (#1462). Skip without touching the remembered value so the
+            # next update that DOES carry dry_time sees the true previous.
+            raw_dry_time = ams_unit.get("dry_time")
+            if raw_dry_time is None:
+                continue
+            try:
+                current = int(raw_dry_time)
+            except (TypeError, ValueError):
+                continue
+            previous = self._previous_dry_times.get(ams_id, 0)
+            self._previous_dry_times[ams_id] = current
+            if previous > 0 and current == 0:
+                logger.info(
+                    "[%s] AMS %d drying complete (dry_time %d → 0)",
+                    self.serial_number,
+                    ams_id,
+                    previous,
+                )
+                self._drying_targets.pop(ams_id, None)
+                if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
 
         # Create a hash of relevant AMS data to detect changes
@@ -4092,6 +4099,15 @@ class BambuMQTTClient:
             self.serial_number,
             wire_json,
         )
+        # Track the active-cycle target so the badge can show "PETG @ 65°C"
+        # while drying. Bambu only echoes dry_time on subsequent pushes.
+        if mode == 1:
+            self._drying_targets[ams_id] = {
+                "filament": filament or "",
+                "temp": int(temp),
+            }
+        else:
+            self._drying_targets.pop(ams_id, None)
         return True
 
     def _handle_kprofile_response(self, data: dict):

+ 65 - 3
backend/app/services/printer_manager.py

@@ -347,7 +347,12 @@ class PrinterManager:
 
             await ws_manager.send_printer_status(
                 printer_id,
-                printer_state_to_dict(state, printer_id, self.get_model(printer_id)),
+                printer_state_to_dict(
+                    state,
+                    printer_id,
+                    self.get_model(printer_id),
+                    self.get_drying_targets(printer_id),
+                ),
             )
         except Exception as e:
             logger.warning(
@@ -556,6 +561,18 @@ class PrinterManager:
         """Get the cached model for a printer."""
         return self._models.get(printer_id)
 
+    def get_drying_targets(self, printer_id: int) -> dict[int, dict] | None:
+        """Get cached active drying target params keyed by AMS id.
+
+        Returned dict shape: ``{ams_id: {"filament": str, "temp": int}}``.
+        Returns ``None`` when the printer is not connected. The cache is
+        seeded by ``send_drying_command(mode=1)`` and cleared when drying
+        stops or on the ``dry_time`` falling edge (handled inside
+        ``BambuMQTTClient``).
+        """
+        client = self._clients.get(printer_id)
+        return client._drying_targets if client else None
+
     def get_all_statuses(self) -> dict[int, PrinterState]:
         """Get status of all connected printers (checks for stale connections)."""
         result = {}
@@ -896,13 +913,21 @@ def resolve_plate_id(state) -> int | None:
     return parse_plate_id(state.gcode_file)
 
 
-def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, model: str | None = None) -> dict:
+def printer_state_to_dict(
+    state: PrinterState,
+    printer_id: int | None = None,
+    model: str | None = None,
+    drying_targets: dict[int, dict] | None = None,
+) -> dict:
     """Convert PrinterState to a JSON-serializable dict.
 
     Args:
         state: The printer state to convert
         printer_id: Optional printer ID for generating cover URLs
         model: Optional printer model for filtering unsupported features
+        drying_targets: Optional per-AMS active-cycle params
+            (``{ams_id: {"filament": str, "temp": int}}``) sourced from the
+            BambuMQTTClient cache so the badge can display "PETG @ 65°C".
     """
     # Parse AMS data from raw_data
     ams_units = []
@@ -988,9 +1013,43 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
             # AMS-HT has 1 tray, regular AMS has 4 trays
             is_ams_ht = len(trays) == 1
 
+            # Active-cycle filament + target temperature for the badge.
+            # Bambu does not echo the cycle's chosen filament/temp on the
+            # per-tick AMS push, so prefer the cached target from the last
+            # ``send_drying_command``. When we have no record (drying
+            # started in a previous backend lifetime, or the cache was
+            # never seeded), fall back to the first loaded tray's
+            # tray_type + RFID-recommended drying_temp — the same heuristic
+            # the popover already uses to seed defaults.
+            ams_id_int = int(ams_data.get("id", 0))
+            target = (drying_targets or {}).get(ams_id_int)
+            dry_target_temp: int | None = None
+            dry_filament: str | None = None
+            if target:
+                temp_val = target.get("temp")
+                fil_val = target.get("filament") or ""
+                if temp_val is not None:
+                    try:
+                        dry_target_temp = int(temp_val)
+                    except (TypeError, ValueError):
+                        dry_target_temp = None
+                if fil_val:
+                    dry_filament = str(fil_val)
+            if dry_target_temp is None or not dry_filament:
+                for tray in trays:
+                    if tray.get("tray_type"):
+                        if not dry_filament:
+                            dry_filament = str(tray["tray_type"])
+                        if dry_target_temp is None and tray.get("drying_temp"):
+                            try:
+                                dry_target_temp = int(tray["drying_temp"])
+                            except (TypeError, ValueError):
+                                pass
+                        break
+
             ams_units.append(
                 {
-                    "id": int(ams_data.get("id", 0)),
+                    "id": ams_id_int,
                     "humidity": humidity_value,
                     "temp": ams_data.get("temp"),
                     "is_ams_ht": is_ams_ht,
@@ -1006,6 +1065,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                     "dry_sub_status": int(ams_data.get("dry_sub_status") or 0),
                     # Cannot-dry reasons from firmware (e.g. 1=InsufficientPower, 8=NeedPluginPower)
                     "dry_sf_reason": list(ams_data.get("dry_sf_reason") or []),
+                    # Active-cycle filament name + target temperature
+                    "dry_target_temp": dry_target_temp,
+                    "dry_filament": dry_filament,
                     # Module type: "ams", "n3f", "n3s" (from get_version)
                     "module_type": str(ams_data.get("module_type") or ""),
                 }

+ 26 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3729,6 +3729,32 @@ class TestSendDryingCommand:
         qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
         assert qos == 1
 
+    def test_start_caches_target_for_badge(self, mqtt_client):
+        """mode=1 send populates _drying_targets so the badge can render it."""
+        mqtt_client.send_drying_command(ams_id=2, temp=65, duration=12, mode=1, filament="PETG")
+        assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65}
+
+    def test_start_overwrites_prior_target_for_same_ams(self, mqtt_client):
+        """A second start on the same AMS replaces the cached target."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
+        mqtt_client.send_drying_command(ams_id=0, temp=70, duration=6, mode=1, filament="ABS")
+        assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70}
+
+    def test_stop_clears_target(self, mqtt_client):
+        """mode=0 send drops the cache so the badge stops showing the target."""
+        mqtt_client.send_drying_command(ams_id=1, temp=55, duration=4, mode=1, filament="PLA")
+        assert 1 in mqtt_client._drying_targets
+        mqtt_client.send_drying_command(ams_id=1, temp=0, duration=0, mode=0)
+        assert 1 not in mqtt_client._drying_targets
+
+    def test_targets_isolated_per_ams_id(self, mqtt_client):
+        """Stopping one AMS doesn't affect another AMS's cached target."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
+        mqtt_client.send_drying_command(ams_id=128, temp=80, duration=6, mode=1, filament="PA-CF")
+        mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
+        assert 0 not in mqtt_client._drying_targets
+        assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80}
+
 
 class TestStartPrintAmsMapping:
     """Tests for ams_mapping/ams_mapping2 construction in start_print().

+ 88 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -1245,6 +1245,94 @@ class TestStatusKeyDryingDedup:
         assert result1["ams"] != result2["ams"]
 
 
+class TestDryingTargetExposure:
+    """Tests for dry_target_temp / dry_filament surfacing on AMS state dict.
+
+    Bambu does not echo the active cycle's chosen filament + target
+    temperature on the per-tick AMS push, only the dry_time countdown.
+    The badge needs the cached target so it can render "PETG @ 65°C".
+    """
+
+    def _state_with_ams(self, ams_data: dict) -> object:
+        state = MagicMock()
+        state.connected = True
+        state.state = "IDLE"
+        state.current_print = None
+        state.subtask_name = None
+        state.gcode_file = None
+        state.progress = 0
+        state.remaining_time = 0
+        state.layer_num = 0
+        state.total_layers = 0
+        state.temperatures = {"nozzle": 25, "bed": 25}
+        state.hms_errors = []
+        state.ams_status_main = 0
+        state.ams_status_sub = 0
+        state.tray_now = None
+        state.wifi_signal = -50
+        state.stg_cur = -1
+        state.raw_data = {"ams": [ams_data]}
+        return state
+
+    def test_cached_target_wins_over_tray_fallback(self):
+        """When the cache has a target for this AMS, use it verbatim — even
+        if the loaded tray's filament/recommended-drying-temp differ."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 600,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "drying_temp": 50, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={0: {"filament": "PETG", "temp": 65}})
+        assert result["ams"][0]["dry_filament"] == "PETG"
+        assert result["ams"][0]["dry_target_temp"] == 65
+
+    def test_falls_back_to_loaded_tray_when_no_cache(self):
+        """No cached target → derive from first loaded tray's tray_type +
+        RFID-recommended drying_temp (popover seed heuristic)."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 600,
+                "tray": [
+                    {"id": 0, "tray_type": "ABS", "drying_temp": 70, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets=None)
+        assert result["ams"][0]["dry_filament"] == "ABS"
+        assert result["ams"][0]["dry_target_temp"] == 70
+
+    def test_returns_none_when_no_cache_and_empty_trays(self):
+        """No cache + no loaded tray with tray_type → both fields are None."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 600,
+                "tray": [{"id": 0}],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={})
+        assert result["ams"][0]["dry_filament"] is None
+        assert result["ams"][0]["dry_target_temp"] is None
+
+    def test_targets_for_other_ams_id_dont_leak(self):
+        """A cached target for AMS 1 must not surface on AMS 0."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 600,
+                "tray": [{"id": 0}],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={1: {"filament": "PETG", "temp": 65}})
+        assert result["ams"][0]["dry_filament"] is None
+        assert result["ams"][0]["dry_target_temp"] is None
+
+
 class TestSupportsChamberTemp:
     """Tests for supports_chamber_temp helper function."""
 

+ 10 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -168,6 +168,7 @@ const DE_COGNATES = [
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'China', 'Proxy', 'Start',
   'Diagnose',  // DE: same spelling/meaning as EN — camera diagnostic button label
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // French cognates — many UI labels overlap with English exactly.
@@ -207,6 +208,7 @@ const FR_COGNATES = [
   'EC984C,#6CD4BC,A66EB9,D87694',
   'Proxy', 'Navigation', 'Budget', 'Commit', 'Designer',
   'ntfy, Pushover, Discord, etc.',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Italian cognates.
@@ -235,6 +237,7 @@ const IT_COGNATES = [
   'Hex: #{{hex}}',
   'EC984C,#6CD4BC,A66EB9,D87694',
   'Proxy', 'Designer',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Japanese: very few cognates because of script difference. Almost
@@ -248,6 +251,7 @@ const JA_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'EC984C,#6CD4BC,A66EB9,D87694',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Portuguese (BR) cognates.
@@ -276,6 +280,7 @@ const PT_BR_COGNATES = [
   'Expand dispatch details', 'Collapse dispatch details',
   'e.g., Home Assistant, OctoPrint', 'ntfy, Pushover, Discord, etc.',
   'Proxy', 'total: {{minutes}} min',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Chinese (Simplified): very few cognates beyond brand names.
@@ -287,6 +292,7 @@ const ZH_CN_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'EC984C,#6CD4BC,A66EB9,D87694',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 const ZH_TW_COGNATES = [
@@ -297,6 +303,7 @@ const ZH_TW_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'EC984C,#6CD4BC,A66EB9,D87694',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Korean: script difference means almost nothing is identical.
@@ -316,6 +323,7 @@ const KO_COGNATES = [
   '{{printer}}: {{error}}',                           // pure placeholders
   '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}', // pure placeholders
   'Obico ML API URL',                                 // product name (Obico)
+  '{{filament}} @ {{temp}}°C',                        // drying badge format
 ];
 
 // Spanish cognates — words/phrases that are genuinely identical in Spanish.
@@ -334,6 +342,7 @@ const ES_COGNATES = [
   'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim
@@ -349,6 +358,7 @@ const TR_COGNATES = [
   '{{count}} filament', '{{printer}}: {{error}}', '{{weight}}g',
   'Filament {{index}} ({{type}})',
   'EC984C,#6CD4BC,A66EB9,D87694',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 const IDENTICAL_TO_EN_ALLOWED = {

+ 2 - 0
frontend/src/api/client.ts

@@ -366,6 +366,8 @@ export interface AMSUnit {
   dry_status: number;     // 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
   dry_sub_status: number; // 0=Off, 1=Heating, 2=Dehumidify
   dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)
+  dry_target_temp: number | null; // Active-cycle target °C (Bambu does not echo)
+  dry_filament: string | null;    // Active-cycle filament name we sent
   module_type: string;    // "ams", "n3f", "n3s"
 }
 

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -535,6 +535,7 @@ export default {
       hours: 'Stunden',
       timeRemaining: '{{time}} verbleibend',
       active: 'Trocknung',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Trocknung nicht unterstützt',
       powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',
       startingDrying: 'Trocknung wird gestartet...',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -538,6 +538,7 @@ export default {
       hours: 'hours',
       timeRemaining: '{{time}} left',
       active: 'Drying',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Drying not supported',
       powerRequired: 'Connect AMS power adapter to enable drying',
       startingDrying: 'Starting drying...',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -535,6 +535,7 @@ export default {
       hours: 'horas',
       timeRemaining: '{{time}} restante',
       active: 'Secando',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Secado no compatible',
       powerRequired: 'Conecte el adaptador de corriente del AMS para activar el secado',
       startingDrying: 'Iniciando el secado...',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -535,6 +535,7 @@ export default {
       hours: 'heures',
       timeRemaining: '{{time}} restant',
       active: 'Séchage',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Séchage non pris en charge',
       powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',
       startingDrying: 'Démarrage du séchage...',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -535,6 +535,7 @@ export default {
       hours: 'ore',
       timeRemaining: '{{time}} rimanente',
       active: 'Essiccazione',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Essiccazione non supportata',
       powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',
       startingDrying: 'Avvio essiccazione...',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -534,6 +534,7 @@ export default {
       hours: '時間',
       timeRemaining: '残り {{time}}',
       active: '乾燥中',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '乾燥非対応',
       powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',
       startingDrying: '乾燥を開始しています...',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -498,6 +498,7 @@ export default {
       hours: '시간',
       timeRemaining: '{{time}} 남음',
       active: '건조 중',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '건조 지원 안 됨',
       powerRequired: '건조를 활성화하려면 AMS 전원 어댑터를 연결하세요',
       startingDrying: '건조 시작 중...',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -535,6 +535,7 @@ export default {
       hours: 'horas',
       timeRemaining: '{{time}} restante',
       active: 'Secagem',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Secagem não suportada',
       powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',
       startingDrying: 'Iniciando secagem...',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -535,6 +535,7 @@ export default {
       hours: 'saat',
       timeRemaining: '{{time}} kaldı',
       active: 'Kurutuluyor',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Kurutma desteklenmiyor',
       powerRequired: 'Kurutmayı etkinleştirmek için AMS güç adaptörünü bağlayın',
       startingDrying: 'Kurutma başlatılıyor...',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -535,6 +535,7 @@ export default {
       hours: '小时',
       timeRemaining: '剩余 {{time}}',
       active: '干燥中',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '不支持干燥',
       powerRequired: '连接AMS电源适配器以启用干燥',
       startingDrying: '正在启动干燥...',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -535,6 +535,7 @@ export default {
       hours: '小時',
       timeRemaining: '剩餘 {{time}}',
       active: '乾燥中',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '不支援乾燥',
       powerRequired: '連線AMS電源介面卡以啟用乾燥',
       startingDrying: '正在啟動乾燥...',

+ 10 - 0
frontend/src/pages/PrintersPage.tsx

@@ -4541,6 +4541,11 @@ function PrinterCard({
                               <div className="flex items-center gap-2 rounded-lg bg-amber-500/10 px-2 py-1 text-[9px]">
                                 <Flame className="w-3 h-3 text-amber-400 shrink-0" />
                                 <span className="text-amber-400 font-medium">{t('printers.drying.active')}</span>
+                                {ams.dry_filament && ams.dry_target_temp != null && (
+                                  <span className="text-amber-300/70">
+                                    {t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })}
+                                  </span>
+                                )}
                                 <span className="text-amber-300/70">
                                   {t('printers.drying.timeRemaining', {
                                     time: ams.dry_time >= 60
@@ -5018,6 +5023,11 @@ function PrinterCard({
                             {ams.dry_time > 0 && (
                               <div className="flex items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-lg bg-amber-500/10 px-2 py-1 text-[9px]">
                                 <Flame className="w-3 h-3 text-amber-400 shrink-0" />
+                                {ams.dry_filament && ams.dry_target_temp != null && (
+                                  <span className="text-amber-300/70 text-[8px] truncate">
+                                    {t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })}
+                                  </span>
+                                )}
                                 <span className="text-amber-300/70 text-[8px] truncate">
                                   {ams.dry_time >= 60
                                     ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BX7ZbFL-.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-DbNkcHxe.js"></script>
+    <script type="module" crossorigin src="/assets/index-BX7ZbFL-.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CfaUjcJN.css">
   </head>
   <body>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio