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

Merge branch 'dev' into feature/slicer-multi-button

MartinNYHC 1 месяц назад
Родитель
Сommit
f7f859b2ff

Разница между файлами не показана из-за своего большого размера
+ 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_drying,
     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.http import build_content_disposition
@@ -578,18 +578,12 @@ async def get_printer_status(
                     dry_target_temp = None
             if 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(
                 AMSUnit(

+ 42 - 18
backend/app/services/ldap_service.py

@@ -14,6 +14,7 @@ import logging
 from dataclasses import dataclass
 
 from ldap3 import ALL, SUBTREE, Connection, Server, Tls
+from ldap3.core.exceptions import LDAPObjectClassError
 
 logger = logging.getLogger(__name__)
 
@@ -155,32 +156,55 @@ def _extract_user_info(
 
     canonical_username = _pick_canonical_username(user_entry, fallback_username)
 
-    # Also search for POSIX groups (memberUid-based) using the service account
-    posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
-    service_conn.search(
-        search_base=config.search_base,
-        search_filter=posix_filter,
-        search_scope=SUBTREE,
-        attributes=["cn"],
-    )
-    for entry in service_conn.entries:
-        groups.append(str(entry.entry_dn))
-
-    # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
-    # Standard Unix semantics treat this as full group membership, so we need
-    # to resolve it to a group DN alongside the memberUid results.
-    if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
-        primary_gid = str(user_entry.gidNumber)
-        primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+    # Also search for POSIX groups, both the memberUid kind and the primary
+    # gidNumber kind. Both filters name the posixGroup object class, and ldap3
+    # validates that name against the schema it fetched at connect time
+    # (get_info=ALL) before it builds the request — so on a directory that
+    # publishes a schema without posixGroup it raises client-side and nothing is
+    # ever sent. A directory with no posixGroup class has no posixGroup entries,
+    # which is exactly the answer the searches would have returned, so the
+    # correct response is to carry on with the memberOf groups collected above.
+    #
+    # Left uncaught, that exception escaped authenticate_ldap_user, and the login
+    # route reports any LDAP error as "Incorrect username or password" — so an
+    # lldap user, whose accounts carry posixAccount but whose directory defines
+    # no group classes beyond groupOfNames, could never log in and had nothing
+    # but a wrong-password message to go on (#2769). This predates the primary
+    # gidNumber lookup: the memberUid filter has named the class since #794.
+    try:
+        posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
         service_conn.search(
             search_base=config.search_base,
-            search_filter=primary_filter,
+            search_filter=posix_filter,
             search_scope=SUBTREE,
             attributes=["cn"],
         )
         for entry in service_conn.entries:
             groups.append(str(entry.entry_dn))
 
+        # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
+        # Standard Unix semantics treat this as full group membership, so we need
+        # to resolve it to a group DN alongside the memberUid results.
+        if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
+            primary_gid = str(user_entry.gidNumber)
+            primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+            service_conn.search(
+                search_base=config.search_base,
+                search_filter=primary_filter,
+                search_scope=SUBTREE,
+                attributes=["cn"],
+            )
+            for entry in service_conn.entries:
+                groups.append(str(entry.entry_dn))
+    except LDAPObjectClassError:
+        # Logged once per authentication, at info: it is the explanation for a
+        # user's POSIX groups being absent from their mapping, and it is not an
+        # error the operator can or should act on.
+        logger.info(
+            "Directory publishes no posixGroup object class; skipping POSIX group lookup "
+            "(memberOf groups are unaffected)"
+        )
+
     # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
     # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
     seen_lower: set[str] = set()

+ 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
 
 
-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
     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
     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
-    "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:
-        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:
-        ``(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:
-        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:
@@ -1333,8 +1334,9 @@ def printer_state_to_dict(
             # 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 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))
             target = (drying_targets or {}).get(ams_id_int)
             dry_target_temp: int | None = None
@@ -1349,14 +1351,8 @@ def printer_state_to_dict(
                         dry_target_temp = None
                 if 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(
                 {

+ 94 - 2
backend/app/services/spoolman_tracking.py

@@ -150,6 +150,62 @@ def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays:
     return slot_id - 1
 
 
+def _resolve_slot_to_tray_fallback(printer_id: int, filament_usage: list[dict]) -> tuple[list[int] | None, str]:
+    """Recover a slot-to-tray mapping at completion when print start captured none.
+
+    ``store_print_data`` can only learn the mapping from two sources: the
+    ``ams_mapping`` Bambuddy intercepts on the printer's local request topic, and
+    a queue item's stored mapping. Neither exists for a print dispatched from
+    Bambu Studio while the printer is cloud-bound — the command travels through
+    Bambu's broker and never appears on the local topic we subscribe to. With
+    ``slot_to_tray`` left NULL, ``_resolve_global_tray_id`` guesses by position:
+    slicer slot 1 to the first loaded tray, slot 2 to the second, and so on. An
+    AMS that isn't loaded in slicer order then charges every slot to the wrong
+    spool, and the archive's filament is rewritten to match, so the print
+    silently changes colour when it finishes (#2768).
+
+    The printer knows the real answer. Its ``mapping`` field carries the actual
+    slot-to-tray assignment for the running job, and for the models that never
+    publish it (A1, P1S, P2S) the 3MF's per-slot colours can be matched against
+    the loaded trays instead. The built-in inventory writer has consulted both
+    for as long as it has resolved mappings at completion; this gives the
+    Spoolman writer the same two fallbacks at the same moment.
+
+    Deliberately at completion rather than inside ``store_print_data``: the
+    printer keeps publishing ``mapping`` long after a job ends — it is still in
+    the status payload while the printer sits idle — so reading it at print start
+    risks stamping the *previous* job's mapping onto this one before the printer
+    has pushed the update. At completion the field unambiguously describes the
+    job that just ran.
+
+    Args:
+        printer_id: Printer whose live state is consulted.
+        filament_usage: The 3MF's per-slot estimates, needed by the colour
+            match. Only the ``slot_id``/``color`` keys are read.
+
+    Returns:
+        ``(mapping, source)``, or ``(None, "none")`` when neither fallback
+        produced anything and the positional default stands.
+    """
+    from backend.app.services.printer_manager import printer_manager
+    from backend.app.services.usage_tracker import _decode_mqtt_mapping, _match_slots_by_color
+
+    state = printer_manager.get_status(printer_id)
+    raw_data = getattr(state, "raw_data", None) if state else None
+    if not raw_data:
+        return None, "none"
+
+    decoded = _decode_mqtt_mapping(raw_data.get("mapping"))
+    if decoded:
+        return decoded, "mqtt"
+
+    matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
+    if matched:
+        return matched, "color_match"
+
+    return None, "none"
+
+
 def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     """Build lookup of global_tray_id -> tray info from printer state.
 
@@ -327,9 +383,11 @@ async def store_print_data(
     # Prefer the explicit mapping captured from the print command, then fall back
     # to any queue mapping stored for scheduled/reprint jobs.
     slot_to_tray = ams_mapping if ams_mapping is not None else None
+    mapping_source = "print_cmd" if slot_to_tray else None
     if not slot_to_tray and queue_item and queue_item.ams_mapping:
         try:
             slot_to_tray = json.loads(queue_item.ams_mapping)
+            mapping_source = "queue"
         except json.JSONDecodeError:
             pass  # Ignore malformed AMS mapping; fall back to default slot assignment
 
@@ -364,8 +422,15 @@ async def store_print_data(
     )
     logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
     logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
-    if slot_to_tray:
-        logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
+    # Logged at info even when there is no mapping: "source: none" here is the
+    # signal that completion will have to fall back, which is the single most
+    # useful line in the log when a print is charged to the wrong spool (#2768).
+    logger.info(
+        "[SPOOLMAN] Print start: archive %s slot_to_tray=%s (source: %s)",
+        archive_id,
+        slot_to_tray,
+        mapping_source or "none",
+    )
     if layer_usage_json:
         logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
 
@@ -819,6 +884,19 @@ async def _report_partial_usage(
         )
         return
 
+    # Same recovery the completion path does, for the same reason: a print
+    # dispatched from Studio over the cloud left print start with no mapping to
+    # store, and both paths below feed ``slot_to_tray`` to
+    # ``_resolve_global_tray_id`` (#2768). An aborted print charges the wrong
+    # spool just as readily as a finished one.
+    if not slot_to_tray:
+        slot_to_tray, _partial_mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage)
+        logger.info(
+            "[SPOOLMAN] Partial usage: slot_to_tray=%s (source: %s)",
+            slot_to_tray,
+            _partial_mapping_source,
+        )
+
     # Try to use accurate G-code parsed data
     if layer_usage:
         layer_usage_int = {
@@ -1000,6 +1078,20 @@ async def report_usage(printer_id: int, archive_id: int):
         # is the print's last valid layer.
         _layer_denom_hint = _total_layers or _current_layer
 
+        # Recover the mapping when print start had nothing to store — the
+        # cloud-dispatched Studio print of #2768. Only the 3MF path consumes
+        # ``slot_to_tray``; the remain-delta path below resolves spools from the
+        # AMS slot directly, so there is nothing to recover for it.
+        mapping_source = "stored" if slot_to_tray else "none"
+        if filament_usage and not slot_to_tray:
+            slot_to_tray, mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage)
+        logger.info(
+            "[SPOOLMAN] Archive %s: slot_to_tray=%s (source: %s)",
+            archive_id,
+            slot_to_tray,
+            mapping_source,
+        )
+
         slot_colors: dict[int, str] = {}
         slot_materials: dict[int, str] = {}
         handled_global_tray_ids: set[int] = set()

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

@@ -11,6 +11,7 @@ are not tested here — they require a live LDAP server.
 """
 
 import pytest
+from ldap3.core.exceptions import LDAPObjectClassError
 
 from backend.app.services.ldap_service import (
     LDAPConfig,
@@ -297,6 +298,11 @@ class _MockConnection:
 
     _search_fixture: dict[str, list] = {}
     _instances: list["_MockConnection"] = []
+    # Filter substring that should raise LDAPObjectClassError instead of
+    # searching, standing in for ldap3's client-side schema validation — it
+    # rejects an object class the server's published schema doesn't define
+    # before the request is ever built (#2769).
+    _raise_object_class_error_on: str | None = None
 
     def __init__(self, *args, **kwargs):
         self.entries: list = []
@@ -320,6 +326,9 @@ class _MockConnection:
         # **kwargs absorbs ldap3 options like size_limit that the real client supports
         self.search_calls.append(search_filter or "")
         self.last_attrs = list(attributes) if attributes is not None else None
+        needle = _MockConnection._raise_object_class_error_on
+        if needle and needle in (search_filter or ""):
+            raise LDAPObjectClassError(f"invalid class in objectClass attribute: {needle}")
         for needle, entries in _MockConnection._search_fixture.items():
             if needle in (search_filter or ""):
                 self.entries = entries
@@ -333,6 +342,7 @@ def mock_ldap(monkeypatch):
     """Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline."""
     _MockConnection._search_fixture = {}
     _MockConnection._instances = []
+    _MockConnection._raise_object_class_error_on = None
     monkeypatch.setattr("backend.app.services.ldap_service.Connection", _MockConnection)
     monkeypatch.setattr("backend.app.services.ldap_service._create_server", lambda config: None)
     return _MockConnection
@@ -433,6 +443,84 @@ class TestAuthenticateLdapUserGroups:
         assert gidnumber_searches == []
 
 
+class TestDirectoryWithoutPosixGroupClass:
+    """A directory whose published schema defines no posixGroup class (#2769).
+
+    ldap3 fetches the schema at connect time (get_info=ALL) and validates object
+    class names in a filter against it before building the request, so both POSIX
+    group searches raise client-side and nothing reaches the server. lldap is the
+    case in the wild: it puts posixAccount on every account it creates, which
+    gives each user a gidNumber, but defines no group class beyond groupOfNames.
+    Left uncaught the exception escaped authenticate_ldap_user and the login route
+    reported it as "Incorrect username or password", so LDAP login was impossible.
+    """
+
+    def test_authenticates_and_keeps_memberof_groups(self, mock_ldap):
+        """The reporter's setup: the mapped membership comes from memberOf, which
+        is read off the user entry and never touches a posixGroup filter."""
+        user_entry = _MockEntry(
+            "uid=peter,ou=people,dc=fablab,dc=test",
+            uid="peter",
+            gidNumber=1001,  # lldap gives every account one
+            memberOf=["cn=AAUStudents,ou=groups,dc=fablab,dc=test"],
+        )
+        mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
+        mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
+
+        info = authenticate_ldap_user(_base_config(), "peter", "password")
+
+        assert info is not None
+        assert info.groups == ["cn=AAUStudents,ou=groups,dc=fablab,dc=test"]
+
+    def test_authenticates_with_no_groups_at_all(self, mock_ldap):
+        """No memberOf either. The user still gets in — auto-provisioning assigns
+        the configured default group, which is the whole point of that setting."""
+        user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001)
+        mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
+        mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
+
+        info = authenticate_ldap_user(_base_config(), "peter", "password")
+
+        assert info is not None
+        assert info.username == "peter"
+        assert info.groups == []
+
+    def test_abandons_the_primary_gid_search_after_the_first_rejection(self, mock_ldap):
+        """Both filters name the same class, so once one is rejected the other
+        cannot succeed. Attempting it would only produce a second identical
+        exception to swallow."""
+        user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001)
+        mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
+        mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
+
+        authenticate_ldap_user(_base_config(), "peter", "password")
+
+        service_conn = _MockConnection._instances[0]
+        posix_searches = [call for call in service_conn.search_calls if "posixGroup" in call]
+        assert len(posix_searches) == 1
+        assert "memberUid=peter" in posix_searches[0]
+
+    def test_a_directory_that_defines_the_class_is_untouched(self, mock_ldap):
+        """The guard must not cost a normal directory its POSIX groups — both
+        searches still run and both results still land."""
+        user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
+        supplementary = _MockEntry("cn=bambuddy-viewers,ou=groups,dc=test,dc=com")
+        primary = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
+
+        mock_ldap._search_fixture = {
+            "(uid=mz)": [user_entry],
+            "memberUid=mz": [supplementary],
+            "gidNumber=10002": [primary],
+        }
+
+        info = authenticate_ldap_user(_base_config(), "mz", "password")
+
+        assert info.groups == [
+            "cn=bambuddy-viewers,ou=groups,dc=test,dc=com",
+            "cn=bambuddy-operators,ou=groups,dc=test,dc=com",
+        ]
+
+
 # ---------------------------------------------------------------------------
 # Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298)
 # ---------------------------------------------------------------------------

+ 58 - 0
backend/tests/unit/services/test_notification_service.py

@@ -919,6 +919,64 @@ class TestHomeAssistantProvider:
             # field is JSON rather than key=value lines.
             assert payload["data"]["ttl"] == 0
 
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_custom_data_keeps_nested_structures(self, service):
+        """Nested objects and lists reach the notify service unaltered (#1441).
+
+        The three tests around this one all use flat scalars, which is also all
+        the placeholder and the wiki showed — so a user asking whether action
+        buttons work had nothing telling them the field is a verbatim
+        pass-through rather than a key/value list. ``actions`` is the case they
+        asked about: a list of objects, the shape an HA automation writes under
+        ``data.actions``. Nothing between the textarea and the POST inspects the
+        parsed value beyond "is it an object", so this asserts the whole
+        structure rather than a key at a time.
+        """
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            actions = [
+                {"action": "SNOOZE_PRINT_FINISHED", "title": "Snooze 20 min"},
+                {"action": "BED_COOL_NOTIFY_ON", "title": "Notify on Bed Cool"},
+            ]
+            config = {
+                "service": "notify.mobile_app_myphone",
+                "data": json.dumps({"ttl": 0, "priority": "high", "group": "3D Printer", "actions": actions}),
+            }
+            success, _ = await service._send_homeassistant(config, "Print Finished", "Print is finished", db=mock_db)
+
+            assert success is True
+            payload = mock_client.post.call_args.kwargs.get("json") or mock_client.post.call_args[1].get("json")
+            assert payload["data"] == {
+                "ttl": 0,
+                "priority": "high",
+                "group": "3D Printer",
+                "actions": actions,
+            }
+            # Spelled out separately: a flattening or scalar-only filter would
+            # still leave the three sibling keys correct, so the equality above
+            # is not on its own evidence that the list survived.
+            assert payload["data"]["actions"] == actions
+
     @pytest.mark.asyncio
     async def test_send_homeassistant_without_data_omits_key(self, service):
         """Without configured data the payload carries no "data" key — the

+ 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_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(
             {
                 "id": 0,
@@ -1392,7 +1393,7 @@ class TestDryingTargetExposure:
         )
         result = printer_state_to_dict(state, drying_targets=None)
         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):
         """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
 
     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(
             {
                 "id": 0,
@@ -1458,24 +1459,42 @@ class TestDryingTargetExposure:
         )
         result = printer_state_to_dict(state, drying_targets={})
         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(
             {
                 "id": 0,
                 "dry_time": 719,
                 "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={})
         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
 
 

+ 243 - 0
backend/tests/unit/services/test_spoolman_slot_mapping_fallback.py

@@ -0,0 +1,243 @@
+"""Slot-to-tray mapping fallbacks on the Spoolman path (#2768).
+
+Bambuddy only learns a print's slot-to-tray mapping at print start when it can
+intercept the command on the printer's local MQTT request topic, or when the
+print came from its own queue. A print dispatched from Bambu Studio while the
+printer is cloud-bound satisfies neither: the command travels through Bambu's
+broker, so ``ActivePrintSpoolman.slot_to_tray`` is NULL and every slot falls
+through to a positional guess (slicer slot 1 to the first loaded tray, and so
+on). The reporter's X1C was loaded out of slicer order, so all four slots were
+charged to the wrong spool and the archive's filament was rewritten to match.
+
+The internal-inventory writer never had this problem because it resolves the
+mapping at completion, where it can read the printer's own ``mapping`` field or
+colour-match the 3MF slots against the loaded trays. These tests cover giving
+the Spoolman writer the same two fallbacks.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.spoolman_tracking import _resolve_slot_to_tray_fallback
+
+
+class _AsyncCtx:
+    """Minimal async context manager yielding a stub db session."""
+
+    def __init__(self, db):
+        self._db = db
+
+    async def __aenter__(self):
+        return self._db
+
+    async def __aexit__(self, *exc):
+        return False
+
+
+def _state(**raw):
+    return SimpleNamespace(raw_data=raw, layer_num=0, total_layers=0, tray_change_log=[])
+
+
+def _patched_pm(state):
+    pm = MagicMock()
+    pm.get_status.return_value = state
+    return pm
+
+
+class TestResolveSlotToTrayFallback:
+    def test_decodes_the_printers_own_mapping_field(self):
+        """The reporter's X1C published mapping=[1, 3, 0, 32768] while their
+        AMS was loaded out of slicer order. Snow-encoded, that is AMS 0 slot 2,
+        AMS 0 slot 4, AMS 0 slot 1, and the AMS-HT — nothing like the
+        positional [0, 1, 2, 3] the fallback-free path assumed."""
+        pm = _patched_pm(_state(mapping=[1, 3, 0, 32768]))
+
+        with patch("backend.app.services.printer_manager.printer_manager", pm):
+            mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
+
+        assert mapping == [1, 3, 0, 128]
+        assert source == "mqtt"
+
+    def test_colour_matches_when_the_printer_publishes_no_mapping(self):
+        """A1/P1S/P2S never publish the mapping field. The 3MF's per-slot
+        colours still identify the trays when each one is unambiguous."""
+        pm = _patched_pm(
+            _state(
+                ams=[
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"},
+                            {"id": 1, "tray_color": "FF0000FF", "tray_type": "PLA"},
+                        ],
+                    }
+                ]
+            )
+        )
+        usage = [{"slot_id": 1, "color": "#FF0000"}, {"slot_id": 2, "color": "#00FF00"}]
+
+        with patch("backend.app.services.printer_manager.printer_manager", pm):
+            mapping, source = _resolve_slot_to_tray_fallback(1, usage)
+
+        assert mapping == [1, 0]
+        assert source == "color_match"
+
+    def test_mapping_field_wins_over_colour_matching(self):
+        """The printer's own field is direct evidence; colour matching is
+        inference. When both are available the field decides."""
+        pm = _patched_pm(
+            _state(
+                mapping=[3],
+                ams=[{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}],
+            )
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager", pm):
+            mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
+
+        assert mapping == [3]
+        assert source == "mqtt"
+
+    def test_reports_none_when_neither_fallback_answers(self):
+        """Ambiguous colours and no mapping field: say so rather than invent
+        one. The caller keeps the positional default, which is no worse than
+        before, and the log names the reason."""
+        pm = _patched_pm(
+            _state(
+                ams=[
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"},
+                            {"id": 1, "tray_color": "FF0000FF", "tray_type": "PLA"},
+                        ],
+                    }
+                ]
+            )
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager", pm):
+            mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
+
+        assert mapping is None
+        assert source == "none"
+
+    def test_reports_none_when_the_printer_is_offline(self):
+        """No live state at completion — the printer dropped off after the
+        print. Nothing to read, and no crash."""
+        with patch("backend.app.services.printer_manager.printer_manager", _patched_pm(None)):
+            mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
+
+        assert mapping is None
+        assert source == "none"
+
+
+class TestReportUsageUsesTheFallback:
+    """End-to-end through report_usage: the fallback has to reach
+    ``_resolve_global_tray_id`` and change which spool is charged."""
+
+    @staticmethod
+    def _run(tracking, state, spool_by_tag, archive):
+        # The first SELECT fetches the tracking row; every later one fetches the
+        # archive for the colour / type rewrites (#1494, #2563).
+        rows = iter([tracking])
+
+        def _next_row(*_args, **_kwargs):
+            result = MagicMock()
+            result.scalar_one_or_none.return_value = next(rows, archive)
+            return result
+
+        db = AsyncMock()
+        db.execute = AsyncMock(side_effect=_next_row)
+        db.delete = AsyncMock()
+        db.commit = AsyncMock()
+
+        client = AsyncMock()
+        client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spool_by_tag.get(tag))
+        client.use_spool = AsyncMock()
+
+        pm = _patched_pm(state)
+
+        async def _go():
+            from backend.app.services.spoolman_tracking import report_usage
+
+            with (
+                patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+                patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+                patch(
+                    "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                    AsyncMock(return_value=client),
+                ),
+                patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
+                patch(
+                    "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                    AsyncMock(return_value=None),
+                ),
+                patch("backend.app.services.printer_manager.printer_manager", pm),
+            ):
+                await report_usage(printer_id=1, archive_id=42)
+
+        return _go, client
+
+    @pytest.mark.asyncio
+    async def test_mqtt_mapping_charges_the_tray_the_printer_named(self):
+        """One-slot print whose filament actually came from AMS slot 4
+        (global tray 3). With no stored mapping the positional default charges
+        global tray 0 — the wrong spool, and the archive is then rewritten to
+        that spool's colour. The printer's mapping field says otherwise."""
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 25.0, "type": "PLA", "color": "#FF0000"}],
+            ams_trays={
+                "0": {"tray_uuid": "TRAY0UUID", "tag_uid": "", "tray_type": "PLA"},
+                "3": {"tray_uuid": "TRAY3UUID", "tag_uid": "", "tray_type": "PLA"},
+            },
+            slot_to_tray=None,
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+        )
+        state = _state(mapping=[3])
+        spools = {
+            "TRAY0UUID": {"id": 100, "filament": {"color_hex": "FFFFFF", "material": "PLA"}},
+            "TRAY3UUID": {"id": 300, "filament": {"color_hex": "FF0000", "material": "PLA"}},
+        }
+        archive = SimpleNamespace(filament_color="#FF0000", filament_type="PLA")
+
+        run, client = self._run(tracking, state, spools, archive)
+        await run()
+
+        client.use_spool.assert_awaited_once_with(300, 25.0)
+        # And the visible half of the bug: the archive keeps the red it was
+        # printed in instead of being rewritten to the wrong spool's white.
+        assert archive.filament_color == "#FF0000"
+
+    @pytest.mark.asyncio
+    async def test_a_stored_mapping_is_never_second_guessed(self):
+        """Print start captured the real ams_mapping (LAN print, or a Bambuddy
+        queue job). That is the slicer's own instruction and outranks anything
+        read back off the printer, whose mapping field may still describe an
+        earlier job."""
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 25.0, "type": "PLA", "color": "#FF0000"}],
+            ams_trays={
+                "0": {"tray_uuid": "TRAY0UUID", "tag_uid": "", "tray_type": "PLA"},
+                "3": {"tray_uuid": "TRAY3UUID", "tag_uid": "", "tray_type": "PLA"},
+            },
+            slot_to_tray=[0],
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+        )
+        state = _state(mapping=[3])
+        spools = {
+            "TRAY0UUID": {"id": 100, "filament": {"color_hex": "FFFFFF", "material": "PLA"}},
+            "TRAY3UUID": {"id": 300, "filament": {"color_hex": "FF0000", "material": "PLA"}},
+        }
+        archive = SimpleNamespace(filament_color="#FF0000", filament_type="PLA")
+
+        run, client = self._run(tracking, state, spools, archive)
+        await run()
+
+        client.use_spool.assert_awaited_once_with(100, 25.0)

+ 3 - 3
frontend/package-lock.json

@@ -3173,9 +3173,9 @@
       }
     },
     "node_modules/brace-expansion": {
-      "version": "5.0.8",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
-      "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
+      "version": "5.0.9",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+      "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
       "dev": true,
       "dependencies": {
         "balanced-match": "^4.0.2"

+ 1 - 1
frontend/package.json

@@ -48,7 +48,7 @@
   },
   "overrides": {
     "minimatch": "^10.2.1",
-    "brace-expansion": "^5.0.8",
+    "brace-expansion": "^5.0.9",
     "js-yaml": "^4.3.0",
     "react-router": "7.18.1"
   },

+ 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)]">
                                 <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>
-                                {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">
-                                    {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 className="text-amber-700/80 dark:text-amber-300/70">
@@ -5453,9 +5459,11 @@ function PrinterCard({
                             {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)]">
                                 <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">
-                                    {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 className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-yKwaHTh1.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-B67xFyee.js"></script>
+    <script type="module" crossorigin src="/assets/index-yKwaHTh1.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-B3jj6-fz.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов