Просмотр исходного кода

fix(printers): REST and WebSocket share the AMS serialiser, override picker sees both nozzles without an AMS

- The printer card is seeded from GET /printers/{id}/status and merged
  with every WebSocket push, but the two built AMS units in separate
  copies. REST lacked dry_status, dry_sub_status and dry_sf_reason, so
  a printer that cannot dry showed an enabled Start button after every
  page load and poll. Both now use ams_units_to_dicts and
  vt_trays_to_dicts; the AMS temperature is a finite number in both.
- The available-filaments route took an empty ams_extruder_map as a
  single-nozzle printer, merging a dual-nozzle printer's external
  spools when it has no AMS. It now uses the scheduler's dual-nozzle
  check (#2771), moved to slot_nozzle.reports_two_nozzles.
maziggy 5 дней назад
Родитель
Сommit
113f08a1a1

+ 2 - 0
CHANGELOG.md

@@ -164,6 +164,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **The AMS drying button knows a printer cannot dry from the first page load** — After a page load and every 30-second refresh, the printer card didn't know the drying status or why drying wasn't possible, so a printer that cannot dry showed an enabled Start button until the next live update (the printer still refused the start). The page load and the live updates now describe AMS units and external spools in exactly the same way. A slot the firmware reports as only its number now also counts as empty from the first load, and the AMS temperature no longer switches between "24°C" and "24.0°C".
+- **The filament override picker keeps both external spools of a dual-nozzle printer without an AMS apart** — On an H2D or X2D feeding only from its external spools, identical spools on the left and right merged into one entry that was offered for both nozzles. The picker now uses the same dual-nozzle check as the print scheduler, so each external spool is offered only for the nozzle it feeds.
 - **A2L: filament used from the AMS Lite is charged to the right spool** — The printer names an AMS Lite tray by its local slot 1-4, and Bambuddy kept that number when it recorded which trays a print used, so the AMS Lite spool was never charged. With a regular AMS beside the AMS Lite, the usage went to that AMS's slot of the same number instead. A virtual printer with **Save AMS mapping** on stored the same local number for queued prints and their archives; with a regular AMS present, such a print was sent to that AMS instead of the AMS Lite.
 - **Proxy virtual printers no longer restart when another virtual printer is saved** — Creating, editing or deleting any virtual printer restarted every running proxy-mode virtual printer, dropping the slicer's connection and any upload in progress. They now restart only when their own settings change. Changing a printer's IP now restarts the proxy virtual printers aimed at it on the new address; before, they kept forwarding to the old one until a virtual printer was saved. Renaming a virtual printer now restarts it, in every mode, so the slicer sees the new name; before, a running virtual printer kept advertising its old name until Bambuddy restarted.
 - **Prints queued through a virtual printer keep the slicer's nozzle choices** —

+ 11 - 128
backend/app/api/routes/printers.py

@@ -66,6 +66,7 @@ from backend.app.services.hms_actions import HMSAction
 from backend.app.services.print_storage import ftp_probe_paths, print_file_reachable_over_ftp
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
+    ams_units_to_dicts,
     display_temperatures,
     drying_screen_only,
     get_derived_status_name,
@@ -76,7 +77,7 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
-    uniform_tray_filament_hint,
+    vt_trays_to_dicts,
 )
 from backend.app.services.printer_media import (
     MAX_PRINTER_ZIP_PREPARE_SECONDS,
@@ -92,8 +93,7 @@ from backend.app.services.printer_media import (
     start_printer_files_job,
 )
 from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id
-from backend.app.services.slot_nozzle import resolve_slot_nozzle
-from backend.app.utils.ams_humidity import ams_humidity_percent
+from backend.app.services.slot_nozzle import reports_two_nozzles, resolve_slot_nozzle
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_IDS_REPLACED_ON_REUSE,
@@ -283,6 +283,9 @@ async def get_available_filaments(
 
         # Get ams_extruder_map for dual-nozzle printers
         ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
+        # Same signal the scheduler matches with (#2771): without an AMS the map
+        # is empty on a dual-nozzle printer too.
+        is_dual_nozzle = reports_two_nozzles(status)
 
         # AMS trays
         for ams_unit in status.raw_data.get("ams", []):
@@ -330,7 +333,7 @@ async def get_available_filaments(
             tray_info_idx = vt.get("tray_info_idx", "")
             tray_sub_brands = vt.get("tray_sub_brands", "") or ""
             vt_id = int(vt.get("id", 254))
-            extruder_id = (255 - vt_id) if ams_extruder_map else None
+            extruder_id = (255 - vt_id) if is_dual_nozzle else None
 
             key = (vt_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
             if key not in seen:
@@ -669,133 +672,13 @@ async def get_printer_status(
 
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         ams_exists = True
-        for ams_data in raw_data["ams"]:
-            # Skip if ams_data is not a dict (defensive check)
-            if not isinstance(ams_data, dict):
-                continue
-            trays = []
-            for tray_data in ams_data.get("tray", []):
-                # Filter out empty/invalid tag values
-                tag_uid = tray_data.get("tag_uid", "")
-                if tag_uid in ("", "0000000000000000"):
-                    tag_uid = None
-                tray_uuid = tray_data.get("tray_uuid", "")
-                if tray_uuid in ("", "00000000000000000000000000000000"):
-                    tray_uuid = None
-
-                # Get K value: first try tray's k field, then lookup from K-profiles
-                k_value = tray_data.get("k")
-                cali_idx = tray_data.get("cali_idx")
-                if k_value is None:
-                    k_value = _kprofile_k(cali_idx, int(ams_data.get("id", 0)), int(tray_data.get("id", 0)))
-
-                trays.append(
-                    AMSTray(
-                        id=tray_data.get("id", 0),
-                        tray_color=tray_data.get("tray_color"),
-                        tray_type=tray_data.get("tray_type"),
-                        tray_sub_brands=tray_data.get("tray_sub_brands"),
-                        tray_id_name=tray_data.get("tray_id_name"),
-                        tray_info_idx=tray_data.get("tray_info_idx"),
-                        remain=tray_data.get("remain", 0),
-                        k=k_value,
-                        cali_idx=cali_idx,
-                        tag_uid=tag_uid,
-                        tray_uuid=tray_uuid,
-                        nozzle_temp_min=tray_data.get("nozzle_temp_min"),
-                        nozzle_temp_max=tray_data.get("nozzle_temp_max"),
-                        drying_temp=tray_data.get("drying_temp"),
-                        drying_time=tray_data.get("drying_time"),
-                        state=tray_data.get("state"),
-                        exists=tray_data.get("exists"),
-                    )
-                )
-            # Percentage only. The 1-5 ``humidity`` index is never substituted
-            # for one -- it is inverted, so it would read as the opposite of
-            # what it means (#3140). See utils/ams_humidity.
-            humidity_pct = ams_humidity_percent(ams_data)
-            humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
-            # 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: 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(
-                AMSUnit(
-                    id=ams_id_int,
-                    humidity=humidity_value,
-                    temp=ams_data.get("temp"),
-                    is_ams_ht=is_ams_ht,
-                    tray=trays,
-                    # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
-                    serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
-                    # Firmware version: populated by _handle_version_info from info.module ams/* entries
-                    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_countdown_stalled=bool(ams_data.get("dry_countdown_stalled") or False),
-                    dry_target_temp=dry_target_temp,
-                    dry_filament=dry_filament,
-                    module_type=str(ams_data.get("module_type") or ""),
-                )
-            )
+        # Same serialiser as the WebSocket push, so the card cannot lose a
+        # field between page load and the first push.
+        ams_units = [AMSUnit(**unit) for unit in ams_units_to_dicts(raw_data["ams"], _kprofile_k, drying_targets)]
 
     # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
     if "vt_tray" in raw_data:
-        for vt_data in raw_data["vt_tray"]:
-            # Filter out empty/invalid tag values for vt_tray
-            vt_tag_uid = vt_data.get("tag_uid", "")
-            if vt_tag_uid in ("", "0000000000000000"):
-                vt_tag_uid = None
-            vt_tray_uuid = vt_data.get("tray_uuid", "")
-            if vt_tray_uuid in ("", "00000000000000000000000000000000"):
-                vt_tray_uuid = None
-
-            # Get K value: first try tray's k field, then lookup from K-profiles
-            vt_k_value = vt_data.get("k")
-            vt_cali_idx = vt_data.get("cali_idx")
-            if vt_k_value is None:
-                # External holder: id 254 is Ext-L, 255 is Ext-R. slot_extruder
-                # takes the 0/1 tray index, so normalise before asking.
-                vt_id = int(vt_data.get("id", 254))
-                vt_k_value = _kprofile_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id)
-
-            tray_id = int(vt_data.get("id", 254))
-            vt_tray.append(
-                AMSTray(
-                    id=tray_id,
-                    tray_color=vt_data.get("tray_color"),
-                    tray_type=vt_data.get("tray_type"),
-                    tray_sub_brands=vt_data.get("tray_sub_brands"),
-                    tray_id_name=vt_data.get("tray_id_name"),
-                    tray_info_idx=vt_data.get("tray_info_idx"),
-                    remain=vt_data.get("remain", 0),
-                    k=vt_k_value,
-                    cali_idx=vt_cali_idx,
-                    tag_uid=vt_tag_uid,
-                    tray_uuid=vt_tray_uuid,
-                    nozzle_temp_min=vt_data.get("nozzle_temp_min"),
-                    nozzle_temp_max=vt_data.get("nozzle_temp_max"),
-                )
-            )
+        vt_tray = [AMSTray(**vt) for vt in vt_trays_to_dicts(raw_data["vt_tray"], _kprofile_k)]
 
     # Convert nozzle info to response format
     nozzles = [

+ 4 - 30
backend/app/services/print_scheduler.py

@@ -65,6 +65,7 @@ from backend.app.services.printer_manager import (
     supports_drying_while_printing,
 )
 from backend.app.services.queue_item_printer import drop_printer_resolved
+from backend.app.services.slot_nozzle import reports_two_nozzles
 from backend.app.services.smart_plug_manager import publish_plug_state, smart_plug_manager
 from backend.app.services.spool_assignment_notifications import (
     _global_tray_from_assignment,
@@ -4370,37 +4371,10 @@ class PrintScheduler:
         # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
         ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
 
-        # Dual-nozzle detection, used below to route external spools to an
-        # extruder (#2771). Mirrors `buildLoadedFilaments` in the frontend,
-        # which was corrected for #1257 while this copy kept the old signal.
-        #
-        # `ams_extruder_map` is derived from AMS info bits, so a dual-nozzle
-        # printer with zero AMS units reports an empty map — and every external
-        # spool then got `extruder_id=None`, which the nozzle-aware filter in
-        # `_match_filaments_to_slots` rejects outright because `None` equals
-        # neither 0 nor 1. On an X2D feeding from external spools only that left
-        # nothing to match, the mapping came back all -1, and the print went out
-        # with `use_ams: true` and no mapping table at all — firmware 0700_8012,
-        # "Failed to get AMS mapping table".
-        #
-        # `nozzles` is always a two-entry list (the state seeds it with two empty
-        # NozzleInfo stubs), so its length proves nothing; only a populated
-        # diameter on the second entry means real hardware. The other two signals
-        # are fallbacks for firmware revisions that surface one but not the
-        # other: a populated `ams_extruder_map` is dual-nozzle by construction,
-        # and so is more than one `vt_tray` entry, since single-nozzle printers
-        # expose exactly one external feed.
-        nozzles = getattr(status, "nozzles", None) or []
+        # Routes external spools to an extruder (#2771). Mirrors
+        # `buildLoadedFilaments` in the frontend.
         vt_trays = status.raw_data.get("vt_tray") or []
-        is_dual_nozzle = bool(
-            (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
-            or ams_extruder_map
-            # isinstance, because a dict here would count its ~30 keys as trays.
-            # bambu_mqtt normalises vt_tray to a list before it reaches raw_data,
-            # so this is unreachable — but the loop below would raise on a dict
-            # and that is the pre-existing behaviour to keep, not to paper over.
-            or (isinstance(vt_trays, list) and len(vt_trays) > 1)
-        )
+        is_dual_nozzle = reports_two_nozzles(status)
 
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])

+ 206 - 164
backend/app/services/printer_manager.py

@@ -1,5 +1,6 @@
 import asyncio
 import logging
+import math
 import re
 import traceback
 from collections.abc import Callable
@@ -1362,6 +1363,209 @@ def resolve_expected_tray(
     return None
 
 
+def _ams_temp(raw: object) -> float | None:
+    """The AMS unit temperature as a number, or None when it is not one."""
+    if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
+        return None
+    try:
+        value = float(raw)
+    except (TypeError, ValueError):
+        return None
+    # NaN or infinity would put a token in the WebSocket frame that the
+    # browser's JSON.parse rejects, dropping the whole status push.
+    return value if math.isfinite(value) else None
+
+
+def ams_units_to_dicts(
+    ams_list: list,
+    resolve_slot_k: Callable,
+    drying_targets: dict[int, dict] | None = None,
+) -> list[dict]:
+    """The AMS units of a printer's ``raw_data["ams"]``, as the printer card reads them.
+
+    The one serialiser for both the WebSocket push and the REST status
+    (routes/printers.py). The card is seeded from REST and shallow-merged with
+    every push, so any field one of them lacks reads as missing on the page:
+    ``exists`` (#2670), the stalled-countdown flag and the drying status were
+    each lost that way while the two were separate copies.
+    """
+    ams_units: list[dict] = []
+    for ams_data in ams_list:
+        # Skip if ams_data is not a dict (defensive check)
+        if not isinstance(ams_data, dict):
+            continue
+        trays = []
+        for tray in ams_data.get("tray", []):
+            tag_uid = tray.get("tag_uid")
+            if tag_uid in ("", "0000000000000000"):
+                tag_uid = None
+            tray_uuid = tray.get("tray_uuid")
+            if tray_uuid in ("", "00000000000000000000000000000000"):
+                tray_uuid = None
+
+            # Get K value: first try tray's k field, then lookup from K-profiles
+            k_value = tray.get("k")
+            cali_idx = tray.get("cali_idx")
+            if k_value is None:
+                k_value = resolve_slot_k(cali_idx, int(ams_data.get("id", 0)), int(tray.get("id", 0)))
+
+            # P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by
+            # @RosdasHH): for a truly empty slot the firmware sends only
+            # {"id": N} — no state, no tray_type, no anything else. Treat
+            # that as the firmware's "no spool" indicator (state=9) so the
+            # assign-spool path in inventory.py can short-circuit a MQTT
+            # publish the firmware would silently drop anyway. The
+            # post-"Reset Slot" A1 Mini BMCU case sends a populated payload
+            # (state=3, tray_type="") — different shape, doesn't match this
+            # guard, still attempts the MQTT push per the #1322 fix.
+            state_val = tray.get("state")
+            if state_val is None and len(tray) == 1 and "id" in tray:
+                state_val = 9
+
+            trays.append(
+                {
+                    "id": int(tray.get("id", 0)),
+                    "tray_color": tray.get("tray_color"),
+                    "tray_type": tray.get("tray_type"),
+                    "tray_sub_brands": tray.get("tray_sub_brands"),
+                    "tray_id_name": tray.get("tray_id_name"),
+                    "tray_info_idx": tray.get("tray_info_idx"),
+                    "remain": tray.get("remain", 0),
+                    "k": k_value,
+                    "cali_idx": cali_idx,
+                    "tag_uid": tag_uid,
+                    "tray_uuid": tray_uuid,
+                    "nozzle_temp_min": tray.get("nozzle_temp_min"),
+                    "nozzle_temp_max": tray.get("nozzle_temp_max"),
+                    "drying_temp": tray.get("drying_temp"),
+                    "drying_time": tray.get("drying_time"),
+                    "state": state_val,
+                    # Firmware's authoritative presence bit (tray_exist_bits),
+                    # set by apply_tray_exist_bits. Without it getEmptySlotKind
+                    # falls back to the firmware-variant state 9/10 heuristic,
+                    # wrong for AMS-HT in both directions (#2670).
+                    "exists": tray.get("exists"),
+                }
+            )
+        # Percentage only — the 1-5 index is inverted and must never stand
+        # in for one (#3140). See utils/ams_humidity.
+        humidity_pct = ams_humidity_percent(ams_data)
+        humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
+
+        # 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), 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))
+        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 not dry_filament:
+            dry_filament = uniform_tray_filament_hint([tray.get("tray_type") or "" for tray in trays])
+
+        ams_units.append(
+            {
+                "id": ams_id_int,
+                "humidity": humidity_value,
+                # A number, as the REST schema has always coerced it: the
+                # firmware sends a string, and the card showed "24°C" after a
+                # page load but "24.0°C" after the first push.
+                "temp": _ams_temp(ams_data.get("temp")),
+                "is_ams_ht": is_ams_ht,
+                "tray": trays,
+                # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
+                "serial_number": str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
+                # Firmware version: populated by _handle_version_info from get_version
+                "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),
+                # Stall flag stamped onto raw_data by the MQTT layer: the
+                # firmware set the countdown but it never ticked. Without it
+                # the card falls back to the amber "active cycle" badge, so
+                # the neutral "Drying not started" state is unreachable.
+                "dry_countdown_stalled": bool(ams_data.get("dry_countdown_stalled") or False),
+                # Drying status from info hex bits (0=Off, 1=Checking, 2=Drying, 3=Cooling, etc.)
+                "dry_status": int(ams_data.get("dry_status") or 0),
+                "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 ""),
+            }
+        )
+    return ams_units
+
+
+def vt_trays_to_dicts(raw: object, resolve_slot_k: Callable) -> list[dict]:
+    """The external spools of a printer's ``raw_data["vt_tray"]``.
+
+    Shared by the WebSocket push and the REST status, like ``ams_units_to_dicts``.
+    """
+    vt_tray: list[dict] = []
+    vt_tray_raw = raw
+    # Defensive: MQTT sends vt_tray as a dict; normalize to list
+    if isinstance(vt_tray_raw, dict):
+        vt_tray_raw = [vt_tray_raw]
+    elif not isinstance(vt_tray_raw, list):
+        vt_tray_raw = []
+    for vt_data in vt_tray_raw:
+        vt_tag_uid = vt_data.get("tag_uid")
+        if vt_tag_uid in ("", "0000000000000000"):
+            vt_tag_uid = None
+        vt_tray_uuid = vt_data.get("tray_uuid")
+        if vt_tray_uuid in ("", "00000000000000000000000000000000"):
+            vt_tray_uuid = None
+
+        # Get K value for vt_tray
+        vt_k_value = vt_data.get("k")
+        vt_cali_idx = vt_data.get("cali_idx")
+        if vt_k_value is None:
+            # External holder: id 254 is Ext-L, 255 is Ext-R. The resolver
+            # takes the 0/1 tray index, so normalise before asking.
+            vt_id = int(vt_data.get("id", 254))
+            vt_k_value = resolve_slot_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id)
+
+        tray_id = int(vt_data.get("id", 254))
+        vt_tray.append(
+            {
+                "id": tray_id,
+                "tray_color": vt_data.get("tray_color"),
+                "tray_type": vt_data.get("tray_type"),
+                "tray_sub_brands": vt_data.get("tray_sub_brands"),
+                "tray_id_name": vt_data.get("tray_id_name"),
+                "tray_info_idx": vt_data.get("tray_info_idx"),
+                "remain": vt_data.get("remain", 0),
+                "k": vt_k_value,
+                "cali_idx": vt_cali_idx,
+                "tag_uid": vt_tag_uid,
+                "tray_uuid": vt_tray_uuid,
+                "nozzle_temp_min": vt_data.get("nozzle_temp_min"),
+                "nozzle_temp_max": vt_data.get("nozzle_temp_max"),
+            }
+        )
+    return vt_tray
+
+
 def printer_state_to_dict(
     state: PrinterState,
     printer_id: int | None = None,
@@ -1390,173 +1594,11 @@ def printer_state_to_dict(
     resolve_slot_k = build_slot_k_resolver(state)
 
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
-        for ams_data in raw_data["ams"]:
-            trays = []
-            for tray in ams_data.get("tray", []):
-                tag_uid = tray.get("tag_uid")
-                if tag_uid in ("", "0000000000000000"):
-                    tag_uid = None
-                tray_uuid = tray.get("tray_uuid")
-                if tray_uuid in ("", "00000000000000000000000000000000"):
-                    tray_uuid = None
-
-                # Get K value: first try tray's k field, then lookup from K-profiles
-                k_value = tray.get("k")
-                cali_idx = tray.get("cali_idx")
-                if k_value is None:
-                    k_value = resolve_slot_k(cali_idx, int(ams_data.get("id", 0)), int(tray.get("id", 0)))
-
-                # P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by
-                # @RosdasHH): for a truly empty slot the firmware sends only
-                # {"id": N} — no state, no tray_type, no anything else. Treat
-                # that as the firmware's "no spool" indicator (state=9) so the
-                # assign-spool path in inventory.py can short-circuit a MQTT
-                # publish the firmware would silently drop anyway. The
-                # post-"Reset Slot" A1 Mini BMCU case sends a populated payload
-                # (state=3, tray_type="") — different shape, doesn't match this
-                # guard, still attempts the MQTT push per the #1322 fix.
-                state_val = tray.get("state")
-                if state_val is None and len(tray) == 1 and "id" in tray:
-                    state_val = 9
-
-                trays.append(
-                    {
-                        "id": int(tray.get("id", 0)),
-                        "tray_color": tray.get("tray_color"),
-                        "tray_type": tray.get("tray_type"),
-                        "tray_sub_brands": tray.get("tray_sub_brands"),
-                        "tray_id_name": tray.get("tray_id_name"),
-                        "tray_info_idx": tray.get("tray_info_idx"),
-                        "remain": tray.get("remain", 0),
-                        "k": k_value,
-                        "cali_idx": cali_idx,
-                        "tag_uid": tag_uid,
-                        "tray_uuid": tray_uuid,
-                        "nozzle_temp_min": tray.get("nozzle_temp_min"),
-                        "nozzle_temp_max": tray.get("nozzle_temp_max"),
-                        "drying_temp": tray.get("drying_temp"),
-                        "drying_time": tray.get("drying_time"),
-                        "state": state_val,
-                        # Firmware's authoritative presence bit (tray_exist_bits),
-                        # set by apply_tray_exist_bits. The REST serializer already
-                        # emits it (routes/printers.py); without it here the WS
-                        # shallow-merge drops `exists` after the first frame and
-                        # getEmptySlotKind falls back to the firmware-variant state
-                        # 9/10 heuristic — wrong for AMS-HT in both directions (#2670).
-                        "exists": tray.get("exists"),
-                    }
-                )
-            # Percentage only — the 1-5 index is inverted and must never stand
-            # in for one (#3140). See utils/ams_humidity.
-            humidity_pct = ams_humidity_percent(ams_data)
-            humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
-
-            # 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), 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))
-            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 not dry_filament:
-                dry_filament = uniform_tray_filament_hint([tray.get("tray_type") or "" for tray in trays])
-
-            ams_units.append(
-                {
-                    "id": ams_id_int,
-                    "humidity": humidity_value,
-                    "temp": ams_data.get("temp"),
-                    "is_ams_ht": is_ams_ht,
-                    "tray": trays,
-                    # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
-                    "serial_number": str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
-                    # Firmware version: populated by _handle_version_info from get_version
-                    "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),
-                    # Stall flag stamped onto raw_data by the MQTT layer: the
-                    # firmware set the countdown but it never ticked. The REST
-                    # serializer already emits it (routes/printers.py); without
-                    # it here the WS shallow-merge replaces the REST-seeded ams
-                    # object a second later, the field reads undefined, and the
-                    # card falls back to the amber "active cycle" badge — so the
-                    # neutral "Drying not started" state would never be reachable
-                    # in the live UI. Same failure mode as `exists` in #2670.
-                    "dry_countdown_stalled": bool(ams_data.get("dry_countdown_stalled") or False),
-                    # Drying status from info hex bits (0=Off, 1=Checking, 2=Drying, 3=Cooling, etc.)
-                    "dry_status": int(ams_data.get("dry_status") or 0),
-                    "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 ""),
-                }
-            )
+        ams_units = ams_units_to_dicts(raw_data["ams"], resolve_slot_k, drying_targets)
 
     # Parse virtual tray (external spool) — now a list
     if "vt_tray" in raw_data:
-        vt_tray_raw = raw_data["vt_tray"]
-        # Defensive: MQTT sends vt_tray as a dict; normalize to list
-        if isinstance(vt_tray_raw, dict):
-            vt_tray_raw = [vt_tray_raw]
-        elif not isinstance(vt_tray_raw, list):
-            vt_tray_raw = []
-        for vt_data in vt_tray_raw:
-            vt_tag_uid = vt_data.get("tag_uid")
-            if vt_tag_uid in ("", "0000000000000000"):
-                vt_tag_uid = None
-            vt_tray_uuid = vt_data.get("tray_uuid")
-            if vt_tray_uuid in ("", "00000000000000000000000000000000"):
-                vt_tray_uuid = None
-
-            # Get K value for vt_tray
-            vt_k_value = vt_data.get("k")
-            vt_cali_idx = vt_data.get("cali_idx")
-            if vt_k_value is None:
-                # External holder: id 254 is Ext-L, 255 is Ext-R. The resolver
-                # takes the 0/1 tray index, so normalise before asking.
-                vt_id = int(vt_data.get("id", 254))
-                vt_k_value = resolve_slot_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id)
-
-            tray_id = int(vt_data.get("id", 254))
-            vt_tray.append(
-                {
-                    "id": tray_id,
-                    "tray_color": vt_data.get("tray_color"),
-                    "tray_type": vt_data.get("tray_type"),
-                    "tray_sub_brands": vt_data.get("tray_sub_brands"),
-                    "tray_id_name": vt_data.get("tray_id_name"),
-                    "tray_info_idx": vt_data.get("tray_info_idx"),
-                    "remain": vt_data.get("remain", 0),
-                    "k": vt_k_value,
-                    "cali_idx": vt_cali_idx,
-                    "tag_uid": vt_tag_uid,
-                    "tray_uuid": vt_tray_uuid,
-                    "nozzle_temp_min": vt_data.get("nozzle_temp_min"),
-                    "nozzle_temp_max": vt_data.get("nozzle_temp_max"),
-                }
-            )
+        vt_tray = vt_trays_to_dicts(raw_data["vt_tray"], resolve_slot_k)
 
     # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
     ams_extruder_map = raw_data.get("ams_extruder_map", {})

+ 26 - 0
backend/app/services/slot_nozzle.py

@@ -108,6 +108,32 @@ def normalise_flow(raw: str | None) -> str | None:
     return text[:2] if text[:2] in ("HH", "HS") else None
 
 
+def reports_two_nozzles(state) -> bool:
+    """Whether the live state shows a dual-nozzle printer.
+
+    Decides which extruder an external spool feeds (254 = left = extruder 1,
+    255 = right = extruder 0). ``ams_extruder_map`` alone is not enough: it is
+    derived from AMS info bits, so a dual-nozzle printer with no AMS reports an
+    empty map, and its external spools then matched no nozzle (#2771).
+
+    ``nozzles`` is always a two-entry list (the state seeds two empty stubs), so
+    its length proves nothing; only a populated diameter on the second entry
+    means real hardware. The other two signals cover firmware revisions that
+    surface one but not the other: a populated ``ams_extruder_map`` is
+    dual-nozzle by construction, and so is more than one ``vt_tray`` entry,
+    since single-nozzle printers expose exactly one external feed.
+    """
+    raw_data = getattr(state, "raw_data", None) or {}
+    nozzles = getattr(state, "nozzles", None) or []
+    vt_trays = raw_data.get("vt_tray") or []
+    return bool(
+        (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
+        or raw_data.get("ams_extruder_map")
+        # isinstance, because a dict here would count its keys as trays.
+        or (isinstance(vt_trays, list) and len(vt_trays) > 1)
+    )
+
+
 def nozzle_flow_for_extruder(state, extruder: int | None, model: str | None = None) -> str | None:
     """The flow type fitted to ``extruder``, or None when the printer is silent.
 

+ 158 - 0
backend/tests/integration/test_ams_rest_ws_parity.py

@@ -0,0 +1,158 @@
+"""The REST status and the WebSocket push describe AMS units the same way.
+
+The printer card is seeded from GET /printers/{id}/status and then
+shallow-merged with every WebSocket push. The two serialisers were separate
+copies, and each field one of them lacked showed up as a UI bug: ``exists``
+(#2670), the stalled-countdown flag, and the drying status -- REST left out
+``dry_status``, ``dry_sub_status`` and ``dry_sf_reason``, so a printer that
+cannot dry showed an enabled Start button after every page load and poll.
+"""
+
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.printer_manager import printer_state_to_dict
+
+RAW = {
+    "ams": [
+        {
+            "id": "0",
+            "humidity": "4",
+            "humidity_raw": "31",
+            "temp": "24.5",
+            "sn": "AMS0SN",
+            "sw_ver": "00.00.06.40",
+            "dry_time": 0,
+            "dry_status": 0,
+            "dry_sub_status": 0,
+            "dry_sf_reason": [1, 8],
+            "module_type": "ams",
+            "tray": [
+                {
+                    "id": "0",
+                    "tray_type": "PLA",
+                    "tray_color": "FF0000FF",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_info_idx": "GFA00",
+                    "remain": 80,
+                    "k": 0.02,
+                    "cali_idx": 3,
+                    "tag_uid": "0000000000000000",
+                    "state": 11,
+                    "exists": True,
+                },
+                # The firmware's whole payload for a physically empty slot.
+                {"id": "1"},
+            ],
+        },
+        {
+            "id": "128",
+            "dry_time": 95,
+            "dry_status": 2,
+            "dry_sub_status": 1,
+            "dry_countdown_stalled": True,
+            "module_type": "n3s",
+            "tray": [{"id": "0", "tray_type": "PETG", "tray_color": "00FF00FF", "remain": 50, "state": 11}],
+        },
+    ],
+    "vt_tray": [
+        {"id": "254", "tray_type": "PETG", "tray_color": "0000FFFF", "remain": 30, "k": 0.03, "cali_idx": 2},
+        {"id": "255", "tray_type": "", "tag_uid": "0000000000000000", "state": 9},
+    ],
+}
+
+DRYING_TARGETS = {128: {"filament": "PETG", "temp": 65}}
+
+
+def _state() -> PrinterState:
+    state = PrinterState()
+    state.connected = True
+    state.state = "IDLE"
+    state.raw_data = json.loads(json.dumps(RAW))
+    return state
+
+
+def _without_nulls(value):
+    """A null field and a missing one read the same on the card, which
+    replaces these lists whole; REST fills its schema defaults, WS leaves them out."""
+    if isinstance(value, dict):
+        return {k: _without_nulls(v) for k, v in value.items() if v is not None}
+    if isinstance(value, list):
+        return [_without_nulls(v) for v in value]
+    return value
+
+
+async def _rest_status(async_client: AsyncClient, printer_factory) -> dict:
+    printer = await printer_factory()
+    with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+        mock_pm.get_status = MagicMock(return_value=_state())
+        mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+        mock_pm.get_drying_targets = MagicMock(return_value=DRYING_TARGETS)
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+    assert response.status_code == 200
+    return response.json()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rest_reports_the_drying_status(async_client: AsyncClient, printer_factory):
+    ams = (await _rest_status(async_client, printer_factory))["ams"]
+
+    assert ams[0]["dry_sf_reason"] == [1, 8]
+    assert ams[1]["dry_status"] == 2
+    assert ams[1]["dry_sub_status"] == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rest_and_websocket_ams_units_match(async_client: AsyncClient, printer_factory):
+    rest = (await _rest_status(async_client, printer_factory))["ams"]
+    ws = json.loads(json.dumps(printer_state_to_dict(_state(), drying_targets=DRYING_TARGETS)["ams"]))
+
+    assert _without_nulls(rest) == _without_nulls(ws)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rest_and_websocket_external_spools_match(async_client: AsyncClient, printer_factory):
+    rest = (await _rest_status(async_client, printer_factory))["vt_tray"]
+    ws = json.loads(json.dumps(printer_state_to_dict(_state(), drying_targets=DRYING_TARGETS)["vt_tray"]))
+
+    assert _without_nulls(rest) == _without_nulls(ws)
+
+
+@pytest.mark.parametrize(
+    ("raw", "expected"),
+    [("24.5", 24.5), (31, 31.0), ("", None), ("--", None), (True, None), (None, None), ("nan", None), ("inf", None)],
+)
+def test_ams_temperature_is_a_finite_number_or_nothing(raw, expected):
+    from backend.app.services.printer_manager import _ams_temp
+
+    assert _ams_temp(raw) == expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rest_survives_the_shapes_websocket_already_tolerated(async_client: AsyncClient, printer_factory):
+    """A non-dict AMS entry and a dict-shaped vt_tray used to 500 the REST route."""
+    printer = await printer_factory()
+    state = _state()
+    state.raw_data = {
+        "ams": ["garbage", {"id": "0", "temp": "--", "tray": []}],
+        "vt_tray": {"id": "254", "tray_type": "PLA", "tray_color": "FFFFFFFF"},
+    }
+    with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+        mock_pm.get_status = MagicMock(return_value=state)
+        mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+        mock_pm.get_drying_targets = MagicMock(return_value={})
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+    assert response.status_code == 200
+    body = response.json()
+    assert [unit["id"] for unit in body["ams"]] == [0]
+    assert body["ams"][0]["temp"] is None
+    assert [vt["id"] for vt in body["vt_tray"]] == [254]

+ 73 - 0
backend/tests/integration/test_available_filaments.py

@@ -217,3 +217,76 @@ class TestAvailableFilaments:
 
         assert response.status_code == 200
         assert response.json() == []
+
+
+class TestExternalSpoolsOnADualNozzlePrinterWithoutAms:
+    """``ams_extruder_map`` is derived from AMS info bits, so a dual-nozzle
+    printer without an AMS reports an empty one. The route took that as
+    "single nozzle": both external spools got ``extruder_id=None``, identical
+    spools merged into one, and the override picker offered it for both
+    nozzles. The scheduler fixed the same signal for #2771."""
+
+    @staticmethod
+    def _spool(vt_id: str) -> dict:
+        return {"id": vt_id, "tray_type": "PETG", "tray_color": "000000FF", "tray_sub_brands": "PETG HF"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_second_nozzle_routes_each_external_spool(self, async_client: AsyncClient, printer_factory):
+        from types import SimpleNamespace
+
+        await printer_factory(name="H2D", model="H2D")
+        status = _make_mock_status(ams_data=[], vt_tray=[self._spool("254"), self._spool("255")])
+        status.nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")]
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+            response = await async_client.get("/api/v1/printers/available-filaments?model=H2D")
+
+        assert response.status_code == 200
+        assert sorted(f["extruder_id"] for f in response.json()) == [0, 1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_second_nozzle_alone_means_two_nozzles(self, async_client: AsyncClient, printer_factory):
+        """A firmware revision that lists only the loaded external feed."""
+        from types import SimpleNamespace
+
+        await printer_factory(name="H2D", model="H2D")
+        status = _make_mock_status(ams_data=[], vt_tray=[self._spool("254")])
+        status.nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")]
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+            response = await async_client.get("/api/v1/printers/available-filaments?model=H2D")
+
+        assert [f["extruder_id"] for f in response.json()] == [1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_two_external_feeds_alone_mean_two_nozzles(self, async_client: AsyncClient, printer_factory):
+        await printer_factory(name="H2D", model="H2D")
+        status = _make_mock_status(ams_data=[], vt_tray=[self._spool("254"), self._spool("255")])
+        status.nozzles = []
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+            response = await async_client.get("/api/v1/printers/available-filaments?model=H2D")
+
+        assert sorted(f["extruder_id"] for f in response.json()) == [0, 1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_single_nozzle_external_spool_has_no_extruder(self, async_client: AsyncClient, printer_factory):
+        from types import SimpleNamespace
+
+        await printer_factory(name="X1C", model="X1C")
+        status = _make_mock_status(ams_data=[], vt_tray=[self._spool("254")])
+        # The state always seeds two nozzle stubs; an empty second one is no nozzle.
+        status.nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="")]
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert [f["extruder_id"] for f in response.json()] == [None]

+ 1 - 1
backend/tests/unit/test_ams_humidity_index_guard_3140.py

@@ -94,7 +94,7 @@ def test_the_websocket_serializer_reports_no_humidity_for_an_index_only_unit():
     result = printer_state_to_dict(PrinterState(connected=True, state="IDLE", raw_data=_index_only_unit()))
 
     assert result["ams"][0]["humidity"] is None
-    assert result["ams"][0]["temp"] == "24.0"  # the other sensor is unaffected
+    assert result["ams"][0]["temp"] == 24.0  # the other sensor is unaffected
 
 
 @pytest.mark.asyncio