Przeglądaj źródła

Remember when each spool was last dried (#2863)

maziggy 5 dni temu
rodzic
commit
84f320b859
100 zmienionych plików z 1301 dodań i 9 usunięć
  1. 6 0
      CHANGELOG.md
  2. 20 0
      backend/app/api/routes/_spoolman_helpers.py
  3. 10 0
      backend/app/api/routes/inventory.py
  4. 22 2
      backend/app/api/routes/spoolman_inventory.py
  5. 7 0
      backend/app/core/database.py
  6. 17 0
      backend/app/main.py
  7. 7 0
      backend/app/models/spool.py
  8. 24 1
      backend/app/schemas/spool.py
  9. 71 0
      backend/app/services/bambu_mqtt.py
  10. 4 0
      backend/app/services/github_backup.py
  11. 5 0
      backend/app/services/github_restore.py
  12. 15 0
      backend/app/services/printer_manager.py
  13. 158 0
      backend/app/services/spool_drying.py
  14. 241 0
      backend/tests/integration/test_spool_drying_2863.py
  15. 40 0
      backend/tests/integration/test_spoolman_inventory_api.py
  16. 107 0
      backend/tests/unit/services/test_bambu_mqtt.py
  17. 19 0
      backend/tests/unit/test_github_backup_spool_fields.py
  18. 30 0
      backend/tests/unit/test_github_restore.py
  19. 72 0
      backend/tests/unit/test_spool_drying_record_2863.py
  20. 174 0
      frontend/src/__tests__/components/SpoolFormLastDried.test.tsx
  21. 7 1
      frontend/src/__tests__/pages/InventoryPageGrouping.test.ts
  22. 105 0
      frontend/src/__tests__/pages/InventoryPageLastDried.test.tsx
  23. 5 0
      frontend/src/api/client.ts
  24. 15 0
      frontend/src/components/SpoolFormModal.tsx
  25. 29 0
      frontend/src/components/spool-form/AdditionalSection.tsx
  26. 7 0
      frontend/src/components/spool-form/types.ts
  27. 3 0
      frontend/src/i18n/locales/de.ts
  28. 3 0
      frontend/src/i18n/locales/en.ts
  29. 3 0
      frontend/src/i18n/locales/es.ts
  30. 3 0
      frontend/src/i18n/locales/fr.ts
  31. 3 0
      frontend/src/i18n/locales/it.ts
  32. 3 0
      frontend/src/i18n/locales/ja.ts
  33. 3 0
      frontend/src/i18n/locales/ko.ts
  34. 3 0
      frontend/src/i18n/locales/nl.ts
  35. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  36. 3 0
      frontend/src/i18n/locales/ru.ts
  37. 3 0
      frontend/src/i18n/locales/sv.ts
  38. 3 0
      frontend/src/i18n/locales/tr.ts
  39. 3 0
      frontend/src/i18n/locales/uk.ts
  40. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  41. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  42. 37 1
      frontend/src/pages/InventoryPage.tsx
  43. 0 0
      static/assets/AdditionalSection-3BKr-yip.js
  44. 0 0
      static/assets/AdditionalSection-B6WMblUU.js
  45. 0 0
      static/assets/AmsUnitCard-D5a7euCZ.js
  46. 0 0
      static/assets/ArchivesPage-Bey6mxdW.js
  47. 0 0
      static/assets/AssignToAmsModal-DFZcEmxU.js
  48. 0 0
      static/assets/CamWallPage-B5jO04uY.js
  49. 0 0
      static/assets/CameraDiagnoseModal-CwoB6c0T.js
  50. 0 0
      static/assets/CameraPage-CtlRTieh.js
  51. 0 0
      static/assets/CameraWall-CnBRKVc6.js
  52. 0 0
      static/assets/ColumnConfigModal-DUYMGUzS.js
  53. 0 0
      static/assets/ConfigureAmsSlotModal-DKVbw1V5.js
  54. 0 0
      static/assets/ConnectAuthorizePage-DX9Qg47q.js
  55. 0 0
      static/assets/ContextMenu-4JKs6J8U.js
  56. 0 0
      static/assets/ExternalLinkPage-Dv5Pni6e.js
  57. 0 1
      static/assets/FileManagerPage-DBtfYDIY.js
  58. 0 0
      static/assets/FileUploadModal-5XyUPp1M.js
  59. 0 0
      static/assets/FinancePage-MQM248BB.js
  60. 0 0
      static/assets/GCodeViewerPage-u7tMfK0M.js
  61. 0 0
      static/assets/GroupEditPage-B1zudt8L.js
  62. 0 0
      static/assets/ImagePreviewModal-DDwlB_X7.js
  63. 0 0
      static/assets/InventoryPage-Cjv3mgeu.js
  64. 0 0
      static/assets/InventoryPage-DbBALa1D.js
  65. 0 0
      static/assets/LibraryTrashPage-CgpeDHOU.js
  66. 0 0
      static/assets/LoginPage-Bn2FDxDx.js
  67. 0 0
      static/assets/MaintenancePage-w71H5HP4.js
  68. 0 0
      static/assets/ModelSourcesPage-CV-ikPBH.js
  69. 0 0
      static/assets/NotificationsPage-CBdOBRKH.js
  70. 0 1
      static/assets/PdfPreviewModal-D0aAUfK3.js
  71. 0 0
      static/assets/PhotoGalleryModal-BdKyjXYz.js
  72. 0 0
      static/assets/PrinterLocationsPage-Bb7lzN87.js
  73. 0 0
      static/assets/PrintersPage-DPT4cM9V.js
  74. 0 0
      static/assets/ProfilesPage-DU86ah1J.js
  75. 0 0
      static/assets/ProjectDetailPage-C-R6U56A.js
  76. 0 0
      static/assets/ProjectsPage-BJGrfpoB.js
  77. 0 0
      static/assets/QueuePage-CrMbJ9EE.js
  78. 0 0
      static/assets/RichTextEditor-DN1-jaEd.js
  79. 0 0
      static/assets/SettingsPage-BEMiqw_a.js
  80. 0 0
      static/assets/SetupPage-CIt6zcHj.js
  81. 0 0
      static/assets/SliceModal-gNieU_I9.js
  82. 0 0
      static/assets/SpoolBuddyAmsPage-PNrXLBej.js
  83. 0 0
      static/assets/SpoolBuddyCalibrationPage-D0Gs5NC7.js
  84. 0 0
      static/assets/SpoolBuddyDashboard-Bvxja7l8.js
  85. 0 0
      static/assets/SpoolBuddyInventoryPage-CwF7CwGP.js
  86. 1 1
      static/assets/SpoolBuddySettingsPage-BQgb_uO0.js
  87. 0 0
      static/assets/SpoolBuddyWriteTagPage-K8IvkOIE.js
  88. 0 0
      static/assets/SpreadsheetPreviewModal-BBW2wsFD.js
  89. 0 0
      static/assets/StatsPage-dlsv0tR5.js
  90. 0 0
      static/assets/StreamOverlayPage-B4YEyfrG.js
  91. 0 0
      static/assets/SystemInfoPage-BNb6QQrK.js
  92. 0 0
      static/assets/de-RHfkhf78.js
  93. 0 0
      static/assets/es-5tXCFuNX.js
  94. 0 0
      static/assets/fr-DF4R3Wbb.js
  95. 0 0
      static/assets/index-DQNUzRiJ.js
  96. 1 1
      static/assets/inventorySearch-CnZFjESL.js
  97. 0 0
      static/assets/it-Dd9dKQH1.js
  98. 0 0
      static/assets/ja-DkzMOqxg.js
  99. 0 0
      static/assets/ko-C2MmhZAd.js
  100. 0 0
      static/assets/locationSensorDefaults-BSW6Nk3d.js

+ 6 - 0
CHANGELOG.md

@@ -5,6 +5,12 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **Each spool remembers when it was last dried (#2863, requested by @USER19464727)** — The AMS humidity describes the unit, not the spool, so once a spool left the AMS there was no telling whether it had been dried.
+  - **Filled in by AMS drying:** when an AMS drying run ends, every spool assigned to a slot of that AMS is marked with the date, the temperature (for runs Bambuddy started; the printer doesn't report it) and how long the run actually lasted.
+  - **Only real drying counts:** the run must have lasted at least half its length. One stopped before then, from Bambuddy, on the printer or because a print took priority, leaves the earlier record alone.
+  - **Where it shows:** a **Last dried** column in the inventory table, visible by default and sortable, and a line on the spool card. **Group similar** keeps a dried spool apart from undried copies of it.
+  - **Set by hand:** **Last dried** in the spool form (**Color & Cost**, when editing) takes a date or **Now** for a spool dried in an external dryer, and clearing it removes the record.
+  - **Spoolman too:** in Spoolman mode the record is kept in three extra fields on the spool. Git backups carry it as well. Wiki updated.
 - **Failure detection can send Obico a different Bambuddy address from External URL (#3235, requested and contributed by @chrisbennight in #3255)** — Obico's ML server fetches each snapshot from Bambuddy itself, and the only address Bambuddy could give it was **External URL**. When that is a public address behind a reverse proxy with its own login, the ML server can't get through, and detection reports a snapshot error on every check.
 - **Failure detection can send Obico a different Bambuddy address from External URL (#3235, requested and contributed by @chrisbennight in #3255)** — Obico's ML server fetches each snapshot from Bambuddy itself, and the only address Bambuddy could give it was **External URL**. When that is a public address behind a reverse proxy with its own login, the ML server can't get through, and detection reports a snapshot error on every check.
   - **The new setting:** **Settings → Failure Detection** has an optional **Bambuddy Internal URL**, used only for those snapshots. Point it at an address the ML server reaches directly, such as a Docker service name (`http://bambuddy:8000`) or a LAN address. Notifications, sign-in redirects and the other integrations keep using External URL.
   - **The new setting:** **Settings → Failure Detection** has an optional **Bambuddy Internal URL**, used only for those snapshots. Point it at an address the ML server reaches directly, such as a Docker service name (`http://bambuddy:8000`) or a LAN address. Notifications, sign-in redirects and the other integrations keep using External URL.
   - **Empty uses External URL,** so nothing changes until you set it, and clearing it goes back to External URL.
   - **Empty uses External URL,** so nothing changes until you set it, and clearing it goes back to External URL.

+ 20 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -46,6 +46,10 @@ class MappedSpoolFields(TypedDict):
     note: str | None
     note: str | None
     added_full: None
     added_full: None
     last_used: str | None
     last_used: str | None
+    # Last drying (#2863), stored under the bambu_last_dried_* extra keys.
+    last_dried_at: str | None
+    last_dried_temp: int | None
+    last_dried_hours: float | None
     encode_time: str | None
     encode_time: str | None
     tag_uid: str | None
     tag_uid: str | None
     tray_uuid: str | None
     tray_uuid: str | None
@@ -262,6 +266,14 @@ def _extract_extra_str(extra: dict, key: str) -> str:
 BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
 BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
 
 
 
 
+# Spool.extra keys holding when the spool was last dried, the target °C and the
+# hours the cycle ran (#2863). Written by services/spool_drying.py and the
+# spool update route; an empty string means unknown or cleared.
+BAMBU_LAST_DRIED_AT_KEY = "bambu_last_dried_at"
+BAMBU_LAST_DRIED_TEMP_KEY = "bambu_last_dried_temp"
+BAMBU_LAST_DRIED_HOURS_KEY = "bambu_last_dried_hours"
+
+
 def _extract_extra_float(extra: dict, key: str) -> float | None:
 def _extract_extra_float(extra: dict, key: str) -> float | None:
     """Extract a JSON-encoded number from a Spoolman extra dict.
     """Extract a JSON-encoded number from a Spoolman extra dict.
 
 
@@ -324,6 +336,11 @@ def parse_spoolman_multi_colors(filament: dict) -> list[str]:
     return [cleaned for token in tokens if (cleaned := token.strip().lstrip("#"))]
     return [cleaned for token in tokens if (cleaned := token.strip().lstrip("#"))]
 
 
 
 
+def _last_dried_temp(extra: dict) -> int | None:
+    value = _extract_extra_float(extra, BAMBU_LAST_DRIED_TEMP_KEY)
+    return int(round(value)) if value is not None and value > 0 else None
+
+
 def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
 def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
     """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
 
 
@@ -496,6 +513,9 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "note": spool.get("comment") or None,
         "note": spool.get("comment") or None,
         "added_full": None,
         "added_full": None,
         "last_used": spool.get("last_used"),
         "last_used": spool.get("last_used"),
+        "last_dried_at": _extract_extra_str(extra, BAMBU_LAST_DRIED_AT_KEY) or None,
+        "last_dried_temp": _last_dried_temp(extra),
+        "last_dried_hours": _extract_extra_float(extra, BAMBU_LAST_DRIED_HOURS_KEY),
         # encode_time semantics differ: local records NFC write time; Spoolman first_used
         # encode_time semantics differ: local records NFC write time; Spoolman first_used
         # records first print use — different events; using first_used as best available proxy.
         # records first print use — different events; using first_used as best available proxy.
         "encode_time": spool.get("first_used"),
         "encode_time": spool.get("first_used"),

+ 10 - 0
backend/app/api/routes/inventory.py

@@ -1638,6 +1638,11 @@ async def update_spool(
     # Auto-lock weight when user explicitly sets weight_used
     # Auto-lock weight when user explicitly sets weight_used
     if "weight_used" in update_data and "weight_locked" not in update_data:
     if "weight_used" in update_data and "weight_locked" not in update_data:
         update_data["weight_locked"] = True
         update_data["weight_locked"] = True
+    # A drying date set by hand is not the AMS cycle the temperature and
+    # hours describe, so they go with it (#2863).
+    if "last_dried_at" in update_data:
+        update_data["last_dried_temp"] = None
+        update_data["last_dried_hours"] = None
 
 
     for field, value in update_data.items():
     for field, value in update_data.items():
         setattr(spool, field, value)
         setattr(spool, field, value)
@@ -1799,6 +1804,11 @@ async def bulk_update_spools(
     # per-spool PATCH behaviour so bulk edits don't desync the lock state.
     # per-spool PATCH behaviour so bulk edits don't desync the lock state.
     if "weight_used" in prepared and "weight_locked" not in prepared:
     if "weight_used" in prepared and "weight_locked" not in prepared:
         prepared["weight_locked"] = True
         prepared["weight_locked"] = True
+    # Same rule as the per-spool PATCH: a hand-set drying date drops the
+    # temperature and hours of the AMS cycle it replaces (#2863).
+    if "last_dried_at" in prepared:
+        prepared["last_dried_temp"] = None
+        prepared["last_dried_hours"] = None
 
 
     result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
     result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
     spools = {s.id: s for s in result.scalars().all()}
     spools = {s.id: s for s in result.scalars().all()}

+ 22 - 2
backend/app/api/routes/spoolman_inventory.py

@@ -13,6 +13,7 @@ import logging
 import re
 import re
 import time
 import time
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
+from datetime import datetime
 
 
 from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
 from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
 from fastapi.responses import JSONResponse
 from fastapi.responses import JSONResponse
@@ -23,6 +24,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes._spoolman_helpers import (
 from backend.app.api.routes._spoolman_helpers import (
+    BAMBU_LAST_DRIED_AT_KEY,
+    BAMBU_LAST_DRIED_HOURS_KEY,
+    BAMBU_LAST_DRIED_TEMP_KEY,
     NormalizedFilament,
     NormalizedFilament,
     NormalizedVendorRef,
     NormalizedVendorRef,
     _map_spoolman_spool,
     _map_spoolman_spool,
@@ -48,7 +52,7 @@ from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
+from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import slot_unlink_grace
 from backend.app.services import slot_unlink_grace
@@ -384,6 +388,9 @@ class SpoolmanInventoryUpdate(BaseModel):
     # schema). Pass an empty string to clear; null/omitted leaves unchanged.
     # schema). Pass an empty string to clear; null/omitted leaves unchanged.
     slicer_filament: str | None = Field(None, max_length=128)
     slicer_filament: str | None = Field(None, max_length=128)
     slicer_filament_name: str | None = Field(None, max_length=255)
     slicer_filament_name: str | None = Field(None, max_length=255)
+    # Set by hand for a drying done outside an AMS; null clears it. Persisted
+    # to the spool's extra dict like the slicer preset (#2863).
+    last_dried_at: datetime | None = None
 
 
     @field_validator("rgba")
     @field_validator("rgba")
     @classmethod
     @classmethod
@@ -395,6 +402,11 @@ class SpoolmanInventoryUpdate(BaseModel):
     def validate_storage_location(cls, v: str | None) -> str | None:
     def validate_storage_location(cls, v: str | None) -> str | None:
         return _validate_storage_location(v)
         return _validate_storage_location(v)
 
 
+    @field_validator("last_dried_at")
+    @classmethod
+    def validate_last_dried_at(cls, v: datetime | None) -> datetime | None:
+        return naive_utc(v)
+
     @model_validator(mode="after")
     @model_validator(mode="after")
     def validate_tag_fields(self) -> SpoolmanInventoryUpdate:
     def validate_tag_fields(self) -> SpoolmanInventoryUpdate:
         # null = remove tag; non-null values rejected (use /tag endpoint to write tags)
         # null = remove tag; non-null values rejected (use /tag endpoint to write tags)
@@ -952,7 +964,8 @@ async def update_spool(
     sf_set = "slicer_filament" in data.model_fields_set
     sf_set = "slicer_filament" in data.model_fields_set
     sfn_set = "slicer_filament_name" in data.model_fields_set
     sfn_set = "slicer_filament_name" in data.model_fields_set
     cn_set = "color_name" in data.model_fields_set
     cn_set = "color_name" in data.model_fields_set
-    if sf_set or sfn_set or cn_set:
+    dried_set = "last_dried_at" in data.model_fields_set
+    if sf_set or sfn_set or cn_set or dried_set:
         # Ensure extra fields are registered (Spoolman rejects PATCHes with
         # Ensure extra fields are registered (Spoolman rejects PATCHes with
         # unknown keys with HTTP 400). Idempotent if startup already ran this.
         # unknown keys with HTTP 400). Idempotent if startup already ran this.
         if sf_set:
         if sf_set:
@@ -968,6 +981,13 @@ async def update_spool(
             new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
             new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
         if cn_set:
         if cn_set:
             new_extra["bambu_color_name"] = json.dumps(data.color_name or "")
             new_extra["bambu_color_name"] = json.dumps(data.color_name or "")
+        if dried_set:
+            # A hand-set date is not the AMS cycle the temperature and hours
+            # describe, so they are cleared with it, as in internal mode.
+            dried_at = data.last_dried_at.isoformat(timespec="seconds") if data.last_dried_at else ""
+            new_extra[BAMBU_LAST_DRIED_AT_KEY] = json.dumps(dried_at)
+            new_extra[BAMBU_LAST_DRIED_TEMP_KEY] = json.dumps("")
+            new_extra[BAMBU_LAST_DRIED_HOURS_KEY] = json.dumps("")
         async with _translate_spoolman_errors():
         async with _translate_spoolman_errors():
             updated = await client.merge_spool_extra(spool_id, new_extra)
             updated = await client.merge_spool_extra(spool_id, new_extra)
 
 

+ 7 - 0
backend/app/core/database.py

@@ -5310,6 +5310,13 @@ async def run_migrations(conn):
     await _safe_execute(conn, f"ALTER TABLE print_log_entries ADD COLUMN wear_cost {float_type}")
     await _safe_execute(conn, f"ALTER TABLE print_log_entries ADD COLUMN wear_cost {float_type}")
     await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN wear_cost {float_type}")
     await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN wear_cost {float_type}")
 
 
+    # Migration: when each spool was last dried, at what temperature and for
+    # how long (#2863). Nullable: no spool has a drying record until one ends.
+    datetime_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
+    await _safe_execute(conn, f"ALTER TABLE spool ADD COLUMN last_dried_at {datetime_type}")
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_dried_temp INTEGER")
+    await _safe_execute(conn, f"ALTER TABLE spool ADD COLUMN last_dried_hours {float_type}")
+
 
 
 async def _backfill_snapshot_prices(conn) -> None:
 async def _backfill_snapshot_prices(conn) -> None:
     """Give the energy snapshots taken before #1251 the price set at upgrade.
     """Give the energy snapshots taken before #1251 the price set at upgrade.

+ 17 - 0
backend/app/main.py

@@ -10084,6 +10084,23 @@ async def lifespan(app: FastAPI):
 
 
     printer_manager.set_drying_complete_callback(on_drying_complete)
     printer_manager.set_drying_complete_callback(on_drying_complete)
 
 
+    async def on_drying_cycle_end(printer_id: int, cycle):
+        """Mark the spools in an AMS that just finished drying as dried (#2863)."""
+        from backend.app.services.spool_drying import record_drying_cycle
+
+        try:
+            async with async_session() as db:
+                await record_drying_cycle(db, printer_id, cycle)
+        except Exception as e:
+            logging.getLogger(__name__).warning(
+                "Failed to record drying for printer %d (AMS %d): %s",
+                printer_id,
+                cycle.ams_id,
+                e,
+            )
+
+    printer_manager.set_drying_cycle_end_callback(on_drying_cycle_end)
+
     async def on_assignment_verified(printer_id: int, ams_id: int, tray_id: int, verified: bool, detail: dict):
     async def on_assignment_verified(printer_id: int, ams_id: int, tray_id: int, verified: bool, detail: dict):
         """Surface the read-back result of a spool assignment to the UI (#2582).
         """Surface the read-back result of a spool assignment to the UI (#2582).
 
 

+ 7 - 0
backend/app/models/spool.py

@@ -73,6 +73,13 @@ class Spool(Base):
     location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), index=True)
     location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), index=True)
 
 
     last_used: Mapped[datetime | None] = mapped_column(DateTime)  # Last time this spool was used in a print
     last_used: Mapped[datetime | None] = mapped_column(DateTime)  # Last time this spool was used in a print
+    # Last drying (#2863): stamped when an AMS drying cycle that held this spool
+    # ends having run at least half its length, or set by hand in the spool
+    # form for an external dryer. Temperature and hours are only known for
+    # cycles Bambuddy watched or started, and a hand-set date clears both.
+    last_dried_at: Mapped[datetime | None] = mapped_column(DateTime)
+    last_dried_temp: Mapped[int | None] = mapped_column(Integer)  # Target °C
+    last_dried_hours: Mapped[float | None] = mapped_column(Float)  # Time actually spent drying
     encode_time: Mapped[datetime | None] = mapped_column(DateTime)  # When spool was encoded/written to tag
     encode_time: Mapped[datetime | None] = mapped_column(DateTime)  # When spool was encoded/written to tag
     tag_uid: Mapped[str | None] = mapped_column(String(32))  # RFID tag UID (up to 32 hex chars)
     tag_uid: Mapped[str | None] = mapped_column(String(32))  # RFID tag UID (up to 32 hex chars)
     tray_uuid: Mapped[str | None] = mapped_column(String(32))  # Bambu Lab spool UUID (32 hex chars)
     tray_uuid: Mapped[str | None] = mapped_column(String(32))  # Bambu Lab spool UUID (32 hex chars)

+ 24 - 1
backend/app/schemas/spool.py

@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
 
 
 from pydantic import BaseModel, Field, field_validator
 from pydantic import BaseModel, Field, field_validator
 
 
@@ -92,6 +92,17 @@ def normalize_effect_type(value: str | None) -> str | None:
     return canonical
     return canonical
 
 
 
 
+def naive_utc(value: datetime | None) -> datetime | None:
+    """Store a datetime the way Bambuddy's naive ``DateTime`` columns hold it: UTC.
+
+    A browser sends local time with an offset. Dropping the offset without
+    converting would shift the stored moment by that offset (#2863).
+    """
+    if value is not None and value.tzinfo is not None:
+        return value.astimezone(timezone.utc).replace(tzinfo=None)
+    return value
+
+
 class SpoolBase(BaseModel):
 class SpoolBase(BaseModel):
     material: str = Field(..., min_length=1, max_length=50)
     material: str = Field(..., min_length=1, max_length=50)
     subtype: str | None = None
     subtype: str | None = None
@@ -197,6 +208,14 @@ class SpoolUpdate(BaseModel):
     tag_type: str | None = None
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     cost_per_kg: float | None = Field(default=None, ge=0)
     weight_locked: bool | None = None
     weight_locked: bool | None = None
+    # Set by hand for a drying done outside an AMS; null clears it (#2863).
+    last_dried_at: datetime | None = None
+
+    @field_validator("last_dried_at")
+    @classmethod
+    def _validate_last_dried_at(cls, v: datetime | None) -> datetime | None:
+        return naive_utc(v)
+
     # User-defined category + per-spool low-stock threshold override (#729).
     # User-defined category + per-spool low-stock threshold override (#729).
     category: str | None = Field(default=None, max_length=50)
     category: str | None = Field(default=None, max_length=50)
     low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
     low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
@@ -268,6 +287,10 @@ class SpoolResponse(SpoolBase):
     rgba: str | None = None
     rgba: str | None = None
     added_full: bool | None = None
     added_full: bool | None = None
     last_used: datetime | None = None
     last_used: datetime | None = None
+    # Last drying (#2863). Only the date is writable, through SpoolUpdate.
+    last_dried_at: datetime | None = None
+    last_dried_temp: int | None = None
+    last_dried_hours: float | None = None
     encode_time: datetime | None = None
     encode_time: datetime | None = None
     tag_uid: str | None = None
     tag_uid: str | None = None
     tray_uuid: str | None = None
     tray_uuid: str | None = None

+ 71 - 0
backend/app/services/bambu_mqtt.py

@@ -686,6 +686,26 @@ def resolve_rack_plan_mapping(
     return wire, None
     return wire, None
 
 
 
 
+@dataclass(frozen=True)
+class DryingCycleEnd:
+    """What Bambuddy saw of one AMS drying cycle, reported when it ends (#2863).
+
+    ``peak_minutes`` is the highest ``dry_time`` observed during the cycle and
+    ``remaining_minutes`` the last one before the drop to 0. ``start_seen`` says
+    whether the countdown was watched from its first minute; when it was not
+    (Bambuddy started mid-cycle) the peak is only a lower bound on the length.
+    ``target_temp`` / ``target_hours`` are known only for cycles Bambuddy itself
+    started, because the printer never echoes them.
+    """
+
+    ams_id: int
+    remaining_minutes: int
+    peak_minutes: int
+    start_seen: bool
+    target_temp: int | None = None
+    target_hours: int | None = None
+
+
 @dataclass
 @dataclass
 class MQTTLogEntry:
 class MQTTLogEntry:
     """Log entry for MQTT message debugging."""
     """Log entry for MQTT message debugging."""
@@ -1240,6 +1260,7 @@ class BambuMQTTClient:
         on_print_progress: Callable[[int], None] | None = None,
         on_print_progress: Callable[[int], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_drying_complete: Callable[[int], None] | None = None,
         on_drying_complete: Callable[[int], None] | None = None,
+        on_drying_cycle_end: Callable[[DryingCycleEnd], None] | None = None,
         on_print_running_observed: Callable[[dict], None] | None = None,
         on_print_running_observed: Callable[[dict], None] | None = None,
         on_finish_photo_moment: Callable[[dict], None] | None = None,
         on_finish_photo_moment: Callable[[dict], None] | None = None,
         on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
         on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
@@ -1272,6 +1293,9 @@ class BambuMQTTClient:
         # the drying cycle just finished (auto- or manually-triggered).
         # the drying cycle just finished (auto- or manually-triggered).
         # Receives the AMS id of the unit that finished drying.
         # Receives the AMS id of the unit that finished drying.
         self.on_drying_complete = on_drying_complete
         self.on_drying_complete = on_drying_complete
+        # #2863: fired on the same edge with what was seen of the cycle, so the
+        # spools in that AMS can be stamped as dried.
+        self.on_drying_cycle_end = on_drying_cycle_end
         # #1485 follow-up: fired the first time we see RUNNING state in a
         # #1485 follow-up: fired the first time we see RUNNING state in a
         # session WHEN on_print_start was suppressed (Bambuddy started mid-
         # session WHEN on_print_start was suppressed (Bambuddy started mid-
         # print, the #1304 first-push guard skipped the start event). Lets
         # print, the #1304 first-push guard skipped the start event). Lets
@@ -1332,6 +1356,11 @@ class BambuMQTTClient:
         # is indistinguishable from the firmware abandoning it — so the cycle-end
         # is indistinguishable from the firmware abandoning it — so the cycle-end
         # log would otherwise blame the printer for our own decision (#2770).
         # log would otherwise blame the printer for our own decision (#2770).
         self._drying_stops_sent: set[int] = set()
         self._drying_stops_sent: set[int] = set()
+        # Per-AMS record of the cycle in progress: the highest dry_time seen,
+        # whether its first minute was observed, and the target we sent for it.
+        # The target is copied here when the countdown starts because a stop
+        # drops _drying_targets before the cycle ends (#2863).
+        self._dry_cycles: dict[int, dict[str, object]] = {}
         # Stage numbers this printer has reported that STAGE_NAMES has no entry
         # Stage numbers this printer has reported that STAGE_NAMES has no entry
         # for, so each is reported once rather than on every transition into it.
         # for, so each is reported once rather than on every transition into it.
         self._unnamed_stages_seen: set[int] = set()
         self._unnamed_stages_seen: set[int] = set()
@@ -3735,8 +3764,22 @@ class BambuMQTTClient:
                     ams_unit.get("dry_status"),
                     ams_unit.get("dry_status"),
                 )
                 )
                 continue
                 continue
+            seen_before = ams_id in self._previous_dry_times
             previous = self._previous_dry_times.get(ams_id, 0)
             previous = self._previous_dry_times.get(ams_id, 0)
             self._previous_dry_times[ams_id] = current
             self._previous_dry_times[ams_id] = current
+            if current > 0:
+                cycle = self._dry_cycles.get(ams_id)
+                if cycle is None or previous == 0:
+                    cycle = {
+                        "peak": current,
+                        # A rise from an observed 0 is the cycle's first minute.
+                        # A first sighting already counting down is not.
+                        "start_seen": seen_before,
+                        "target": self._drying_targets.get(ams_id),
+                    }
+                    self._dry_cycles[ams_id] = cycle
+                elif current > int(cycle["peak"]):
+                    cycle["peak"] = current
             # Stall detection: stamp value CHANGES only — a live countdown
             # Stall detection: stamp value CHANGES only — a live countdown
             # decrements once a minute, so repeats of the same value within
             # decrements once a minute, so repeats of the same value within
             # the minute must not refresh the stamp, and a frame without a
             # the minute must not refresh the stamp, and a frame without a
@@ -3753,6 +3796,9 @@ class BambuMQTTClient:
                 self._log_drying_cycle_end(ams_id, previous, ams_unit, self._drying_targets.pop(ams_id, None))
                 self._log_drying_cycle_end(ams_id, previous, ams_unit, self._drying_targets.pop(ams_id, None))
                 if self.on_drying_complete:
                 if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
                     self.on_drying_complete(ams_id)
+                cycle = self._dry_cycles.pop(ams_id, None) or {"peak": previous, "start_seen": False, "target": None}
+                if self.on_drying_cycle_end:
+                    self.on_drying_cycle_end(self._drying_cycle_end(ams_id, previous, cycle))
 
 
         # Create a hash of relevant AMS data to detect changes.
         # Create a hash of relevant AMS data to detect changes.
         # Hash the MERGED state, not the raw incoming ams_list: a removal signalled
         # Hash the MERGED state, not the raw incoming ams_list: a removal signalled
@@ -3790,6 +3836,27 @@ class BambuMQTTClient:
         if self._pending_assignments:
         if self._pending_assignments:
             self._check_assignment_verifications()
             self._check_assignment_verifications()
 
 
+    @staticmethod
+    def _drying_cycle_end(ams_id: int, remaining: int, cycle: dict[str, object]) -> DryingCycleEnd:
+        """Build the cycle-end report from the record kept while it ran."""
+        target = cycle.get("target")
+        target_temp: int | None = None
+        target_hours: int | None = None
+        if isinstance(target, dict):
+            try:
+                target_temp = int(target.get("temp") or 0) or None
+                target_hours = int(target.get("duration_hours") or 0) or None
+            except (TypeError, ValueError):
+                target_temp = target_hours = None
+        return DryingCycleEnd(
+            ams_id=ams_id,
+            remaining_minutes=remaining,
+            peak_minutes=max(int(cycle.get("peak") or 0), remaining),
+            start_seen=bool(cycle.get("start_seen")),
+            target_temp=target_temp,
+            target_hours=target_hours,
+        )
+
     def _log_drying_cycle_end(
     def _log_drying_cycle_end(
         self,
         self,
         ams_id: int,
         ams_id: int,
@@ -6674,6 +6741,10 @@ class BambuMQTTClient:
                 "duration_hours": int(duration),
                 "duration_hours": int(duration),
             }
             }
             self._drying_stops_sent.discard(ams_id)
             self._drying_stops_sent.discard(ams_id)
+            # A start sent while a cycle is still counting down replaces it
+            # without dry_time passing through 0; begin a fresh record so the
+            # new target and length are the ones reported (#2863).
+            self._dry_cycles.pop(ams_id, None)
         else:
         else:
             self._drying_targets.pop(ams_id, None)
             self._drying_targets.pop(ams_id, None)
             # Remember that this cycle's end is ours, so the cycle-end log
             # Remember that this cycle's end is ours, so the cycle-end log

+ 4 - 0
backend/app/services/github_backup.py

@@ -788,6 +788,10 @@ class GitHubBackupService:
                 "category": s.category,
                 "category": s.category,
                 "low_stock_threshold_pct": s.low_stock_threshold_pct,
                 "low_stock_threshold_pct": s.low_stock_threshold_pct,
                 "storage_location": s.storage_location,
                 "storage_location": s.storage_location,
+                # Last drying (#2863).
+                "last_dried_at": str(s.last_dried_at) if s.last_dried_at else None,
+                "last_dried_temp": s.last_dried_temp,
+                "last_dried_hours": s.last_dried_hours,
                 "tag_uid": s.tag_uid,
                 "tag_uid": s.tag_uid,
                 "tray_uuid": s.tray_uuid,
                 "tray_uuid": s.tray_uuid,
                 "data_origin": s.data_origin,
                 "data_origin": s.data_origin,

+ 5 - 0
backend/app/services/github_restore.py

@@ -1324,6 +1324,11 @@ class GitHubRestoreService:
             for late_field in ("material_number", "category", "low_stock_threshold_pct", "storage_location"):
             for late_field in ("material_number", "category", "low_stock_threshold_pct", "storage_location"):
                 if late_field in entry:
                 if late_field in entry:
                     fields[late_field] = entry[late_field]
                     fields[late_field] = entry[late_field]
+            # Last drying (#2863), same late-field rule.
+            if "last_dried_at" in entry:
+                fields["last_dried_at"] = _parse_dt(entry.get("last_dried_at"))
+                fields["last_dried_temp"] = entry.get("last_dried_temp")
+                fields["last_dried_hours"] = entry.get("last_dried_hours")
 
 
             if existing is not None:
             if existing is not None:
                 if old_id is not None:
                 if old_id is not None:

+ 15 - 0
backend/app/services/printer_manager.py

@@ -11,6 +11,7 @@ from backend.app.models.printer import Printer
 from backend.app.services.bambu_mqtt import (
 from backend.app.services.bambu_mqtt import (
     STAGE_NAMES,
     STAGE_NAMES,
     BambuMQTTClient,
     BambuMQTTClient,
+    DryingCycleEnd,
     MQTTLogEntry,
     MQTTLogEntry,
     PrinterState,
     PrinterState,
     get_stage_name,
     get_stage_name,
@@ -406,6 +407,7 @@ class PrinterManager:
         self._on_print_progress: Callable[[int, int], None] | None = None
         self._on_print_progress: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
+        self._on_drying_cycle_end: Callable[[int, DryingCycleEnd], None] | None = None
         self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
         self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
         self._on_tray_change: Callable[[int, int, int], None] | None = None
         self._on_tray_change: Callable[[int, int, int], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
@@ -678,6 +680,14 @@ class PrinterManager:
         """
         """
         self._on_drying_complete = callback
         self._on_drying_complete = callback
 
 
+    def set_drying_cycle_end_callback(self, callback: Callable[[int, DryingCycleEnd], None]):
+        """Set callback for the end of an AMS drying cycle (#2863).
+
+        Receives ``(printer_id, DryingCycleEnd)`` on the same edge as the
+        drying-complete callback, with what was seen of the cycle.
+        """
+        self._on_drying_cycle_end = callback
+
     def set_assignment_verified_callback(self, callback: Callable[[int, int, int, bool, dict], None]):
     def set_assignment_verified_callback(self, callback: Callable[[int, int, int, bool, dict], None]):
         """Set callback for spool-assignment read-back verification (#2582).
         """Set callback for spool-assignment read-back verification (#2582).
 
 
@@ -795,6 +805,10 @@ class PrinterManager:
             if self._on_drying_complete:
             if self._on_drying_complete:
                 self._schedule_async(self._on_drying_complete(printer_id, ams_id))
                 self._schedule_async(self._on_drying_complete(printer_id, ams_id))
 
 
+        def on_drying_cycle_end(cycle: DryingCycleEnd):
+            if self._on_drying_cycle_end:
+                self._schedule_async(self._on_drying_cycle_end(printer_id, cycle))
+
         def on_assignment_verified(ams_id: int, tray_id: int, verified: bool, detail: dict):
         def on_assignment_verified(ams_id: int, tray_id: int, verified: bool, detail: dict):
             if self._on_assignment_verified:
             if self._on_assignment_verified:
                 self._schedule_async(self._on_assignment_verified(printer_id, ams_id, tray_id, verified, detail))
                 self._schedule_async(self._on_assignment_verified(printer_id, ams_id, tray_id, verified, detail))
@@ -817,6 +831,7 @@ class PrinterManager:
             on_print_progress=on_print_progress,
             on_print_progress=on_print_progress,
             on_bed_temp_update=on_bed_temp_update,
             on_bed_temp_update=on_bed_temp_update,
             on_drying_complete=on_drying_complete,
             on_drying_complete=on_drying_complete,
+            on_drying_cycle_end=on_drying_cycle_end,
             on_print_running_observed=on_print_running_observed,
             on_print_running_observed=on_print_running_observed,
             on_finish_photo_moment=on_finish_photo_moment,
             on_finish_photo_moment=on_finish_photo_moment,
             on_assignment_verified=on_assignment_verified,
             on_assignment_verified=on_assignment_verified,

+ 158 - 0
backend/app/services/spool_drying.py

@@ -0,0 +1,158 @@
+"""Record on each spool when it was last dried (#2863).
+
+An AMS drying cycle dries every spool in that unit, but the AMS humidity only
+describes the unit, so once a spool is moved the fact is lost. When a cycle
+ends, every spool assigned to a slot of that AMS is stamped with the time, the
+target temperature and the hours it actually ran, in whichever inventory holds
+the slot assignments.
+
+A cycle counts when it ran at least ``DRIED_MIN_FRACTION`` of its length. One
+stopped a few minutes in, by the user, the firmware or a print taking
+priority, has not dried anything and must not overwrite an earlier, real
+drying.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes._spoolman_helpers import (
+    BAMBU_LAST_DRIED_AT_KEY,
+    BAMBU_LAST_DRIED_HOURS_KEY,
+    BAMBU_LAST_DRIED_TEMP_KEY,
+)
+from backend.app.services.bambu_mqtt import DryingCycleEnd
+from backend.app.utils.local_time import utcnow_naive
+
+logger = logging.getLogger(__name__)
+
+# Share of a cycle's length that must have run for its spools to count as dried.
+DRIED_MIN_FRACTION = 0.5
+
+# A cycle that runs to term ends with its countdown all but exhausted; the last
+# dry_time seen can trail by a push or two. Same margin the MQTT client uses to
+# tell a finished cycle from an aborted one.
+RAN_TO_TERM_MINUTES = 5
+
+
+@dataclass(frozen=True)
+class DryingRecord:
+    temp: int | None
+    hours: float | None
+
+
+def drying_record(cycle: DryingCycleEnd) -> DryingRecord | None:
+    """What a finished cycle says about its spools, or None when it does not count.
+
+    The cycle's length is the requested duration when Bambuddy started it, else
+    the highest countdown seen. When the start was not watched and nothing was
+    requested, that peak understates the length, so the elapsed share is a
+    lower bound and the hours are left unknown rather than understated. A cycle
+    that ran to term counts either way.
+    """
+    requested = (cycle.target_hours or 0) * 60
+    length = max(cycle.peak_minutes, requested)
+    elapsed = max(0, length - cycle.remaining_minutes)
+    ran_to_term = cycle.remaining_minutes <= RAN_TO_TERM_MINUTES
+    if not ran_to_term and (length <= 0 or elapsed / length < DRIED_MIN_FRACTION):
+        return None
+    length_known = cycle.start_seen or requested > 0
+    hours = round(elapsed / 60, 1) if length_known and elapsed > 0 else None
+    return DryingRecord(temp=cycle.target_temp, hours=hours)
+
+
+async def _spoolman_spool_ids(db: AsyncSession, printer_id: int, ams_id: int) -> list[int]:
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+    result = await db.execute(
+        select(SpoolmanSlotAssignment.spoolman_spool_id).where(
+            SpoolmanSlotAssignment.printer_id == printer_id,
+            SpoolmanSlotAssignment.ams_id == ams_id,
+        )
+    )
+    return sorted(set(result.scalars().all()))
+
+
+async def _stamp_internal(db: AsyncSession, printer_id: int, ams_id: int, record: DryingRecord) -> int:
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+
+    result = await db.execute(
+        select(Spool)
+        .join(SpoolAssignment, SpoolAssignment.spool_id == Spool.id)
+        .where(SpoolAssignment.printer_id == printer_id, SpoolAssignment.ams_id == ams_id)
+    )
+    spools = list(result.scalars().unique().all())
+    now = utcnow_naive()
+    for spool in spools:
+        spool.last_dried_at = now
+        spool.last_dried_temp = record.temp
+        spool.last_dried_hours = record.hours
+    if spools:
+        await db.commit()
+    return len(spools)
+
+
+async def _stamp_spoolman(spool_ids: list[int], record: DryingRecord) -> int:
+    from backend.app.services.spoolman import get_spoolman_client
+
+    client = await get_spoolman_client()
+    if client is None:
+        logger.info("Drying not recorded on Spoolman spools %s: Spoolman is not connected", spool_ids)
+        return 0
+    # JSON strings: the form Spoolman's default "text" extra fields accept.
+    fields = {
+        BAMBU_LAST_DRIED_AT_KEY: json.dumps(utcnow_naive().isoformat(timespec="seconds")),
+        # An empty string clears a value left by an earlier cycle.
+        BAMBU_LAST_DRIED_TEMP_KEY: json.dumps("" if record.temp is None else str(record.temp)),
+        BAMBU_LAST_DRIED_HOURS_KEY: json.dumps("" if record.hours is None else str(record.hours)),
+    }
+    stamped = 0
+    for spool_id in spool_ids:
+        try:
+            await client.merge_spool_extra(spool_id, fields)
+            stamped += 1
+        except Exception as exc:  # noqa: BLE001 — one unreachable spool must not skip the rest
+            logger.warning("Could not record drying on Spoolman spool %d: %s", spool_id, exc)
+    return stamped
+
+
+async def record_drying_cycle(db: AsyncSession, printer_id: int, cycle: DryingCycleEnd) -> int:
+    """Stamp the spools of the AMS that just finished drying. Returns how many."""
+    from backend.app.services.inventory_mode import spoolman_owns_assignments
+
+    record = drying_record(cycle)
+    if record is None:
+        logger.info(
+            "Printer %d AMS %d drying ended with %d of %d minutes left: too short to mark its spools as dried",
+            printer_id,
+            cycle.ams_id,
+            cycle.remaining_minutes,
+            max(cycle.peak_minutes, (cycle.target_hours or 0) * 60),
+        )
+        return 0
+
+    if await spoolman_owns_assignments(db):
+        spool_ids = await _spoolman_spool_ids(db, printer_id, cycle.ams_id)
+        stamped = await _stamp_spoolman(spool_ids, record) if spool_ids else 0
+    else:
+        stamped = await _stamp_internal(db, printer_id, cycle.ams_id, record)
+
+    logger.info(
+        "Printer %d AMS %d drying finished (%s °C, %s h): marked %d spool(s) as dried",
+        printer_id,
+        cycle.ams_id,
+        record.temp if record.temp is not None else "?",
+        record.hours if record.hours is not None else "?",
+        stamped,
+    )
+    if stamped:
+        from backend.app.core.websocket import ws_manager
+
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return stamped

+ 241 - 0
backend/tests/integration/test_spool_drying_2863.py

@@ -0,0 +1,241 @@
+"""Each spool remembers when it was last dried (#2863).
+
+An AMS drying cycle that ran at least half its length stamps every spool
+assigned to that AMS, in whichever inventory holds the slot assignments. The
+date can also be set by hand, which clears the temperature and hours that
+described the AMS cycle.
+"""
+
+import json
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.bambu_mqtt import DryingCycleEnd
+from backend.app.services.spool_drying import record_drying_cycle
+
+FULL_CYCLE = DryingCycleEnd(
+    ams_id=0, remaining_minutes=1, peak_minutes=480, start_seen=True, target_temp=55, target_hours=8
+)
+SHORT_CYCLE = DryingCycleEnd(
+    ams_id=0, remaining_minutes=470, peak_minutes=480, start_seen=True, target_temp=55, target_hours=8
+)
+
+
+async def _assign(db: AsyncSession, printer_id: int, ams_id: int, tray_id: int) -> Spool:
+    spool = Spool(material="PLA", rgba="FF0000FF")
+    db.add(spool)
+    await db.flush()
+    db.add(SpoolAssignment(spool_id=spool.id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
+    await db.commit()
+    return spool
+
+
+@pytest.fixture
+def no_broadcast():
+    with patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()) as broadcast:
+        yield broadcast
+
+
+class TestBuiltInInventory:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_full_cycle_stamps_only_the_spools_in_that_ams(self, printer_factory, db_session, no_broadcast):
+        printer = await printer_factory(name="H2D")
+        in_ams = [await _assign(db_session, printer.id, 0, tray) for tray in (0, 1)]
+        other_ams = await _assign(db_session, printer.id, 1, 0)
+
+        assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 2
+
+        for spool in in_ams:
+            await db_session.refresh(spool)
+            assert spool.last_dried_at is not None
+            assert spool.last_dried_temp == 55
+            assert spool.last_dried_hours == 8.0
+        await db_session.refresh(other_ams)
+        assert other_ams.last_dried_at is None
+        no_broadcast.assert_awaited_once_with({"type": "inventory_changed"})
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cycle_stopped_early_keeps_the_earlier_drying(self, printer_factory, db_session, no_broadcast):
+        printer = await printer_factory(name="H2D")
+        spool = await _assign(db_session, printer.id, 0, 0)
+        earlier = datetime(2026, 9, 1, 12, 0)
+        spool.last_dried_at = earlier
+        spool.last_dried_temp = 65
+        spool.last_dried_hours = 12.0
+        await db_session.commit()
+
+        assert await record_drying_cycle(db_session, printer.id, SHORT_CYCLE) == 0
+
+        await db_session.refresh(spool)
+        assert spool.last_dried_at == earlier
+        assert spool.last_dried_temp == 65
+        no_broadcast.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_mode_leaves_built_in_rows_alone(self, printer_factory, db_session, no_broadcast):
+        """The built-in assignments are preserved while Spoolman is active
+        (#2812); they do not describe what is in the AMS."""
+        printer = await printer_factory(name="H2D")
+        spool = await _assign(db_session, printer.id, 0, 0)
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        await db_session.commit()
+
+        with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=None)):
+            await record_drying_cycle(db_session, printer.id, FULL_CYCLE)
+
+        await db_session.refresh(spool)
+        assert spool.last_dried_at is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hand_set_date_clears_temperature_and_hours(self, async_client: AsyncClient, db_session):
+        spool = Spool(
+            material="PLA",
+            last_dried_at=datetime(2026, 9, 1, 12, 0),
+            last_dried_temp=55,
+            last_dried_hours=8.0,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+
+        response = await async_client.patch(
+            f"/api/v1/inventory/spools/{spool.id}", json={"last_dried_at": "2026-10-06T14:30:00+02:00"}
+        )
+
+        assert response.status_code == 200
+        body = response.json()
+        # Stored as UTC, the way every naive DateTime column holds it.
+        assert body["last_dried_at"].startswith("2026-10-06T12:30:00")
+        assert body["last_dried_temp"] is None
+        assert body["last_dried_hours"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_null_clears_the_date(self, async_client: AsyncClient, db_session):
+        spool = Spool(material="PLA", last_dried_at=datetime(2026, 9, 1, 12, 0), last_dried_temp=55)
+        db_session.add(spool)
+        await db_session.commit()
+
+        response = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"last_dried_at": None})
+
+        assert response.status_code == 200
+        assert response.json()["last_dried_at"] is None
+        assert response.json()["last_dried_temp"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unrelated_edit_keeps_the_record(self, async_client: AsyncClient, db_session):
+        spool = Spool(material="PLA", last_dried_at=datetime(2026, 9, 1, 12, 0), last_dried_temp=55)
+        db_session.add(spool)
+        await db_session.commit()
+
+        response = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"note": "shelf 2"})
+
+        assert response.status_code == 200
+        assert response.json()["last_dried_at"].startswith("2026-09-01T12:00:00")
+        assert response.json()["last_dried_temp"] == 55
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_set_date_clears_temperature_and_hours(self, async_client: AsyncClient, db_session):
+        spools = [Spool(material="PLA", last_dried_temp=55, last_dried_hours=8.0) for _ in range(2)]
+        db_session.add_all(spools)
+        await db_session.commit()
+
+        response = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [s.id for s in spools], "update": {"last_dried_at": "2026-10-06T12:30:00Z"}},
+        )
+
+        assert response.status_code == 200
+        for spool in spools:
+            await db_session.refresh(spool)
+            assert spool.last_dried_at == datetime(2026, 10, 6, 12, 30)
+            assert spool.last_dried_temp is None
+            assert spool.last_dried_hours is None
+
+
+class TestSpoolmanInventory:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_full_cycle_writes_the_extra_fields(self, printer_factory, db_session, no_broadcast):
+        printer = await printer_factory(name="H2D")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        for tray, spool_id in ((0, 11), (1, 12)):
+            db_session.add(
+                SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=tray, spoolman_spool_id=spool_id)
+            )
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=1, tray_id=0, spoolman_spool_id=13))
+        await db_session.commit()
+        client = MagicMock()
+        client.merge_spool_extra = AsyncMock(return_value={})
+
+        with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+            assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 2
+
+        assert [c.args[0] for c in client.merge_spool_extra.call_args_list] == [11, 12]
+        fields = client.merge_spool_extra.call_args.args[1]
+        assert json.loads(fields["bambu_last_dried_temp"]) == "55"
+        assert json.loads(fields["bambu_last_dried_hours"]) == "8.0"
+        assert datetime.fromisoformat(json.loads(fields["bambu_last_dried_at"]))
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_one_failing_spool_does_not_skip_the_rest(self, printer_factory, db_session, no_broadcast):
+        printer = await printer_factory(name="H2D")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        for tray, spool_id in ((0, 11), (1, 12)):
+            db_session.add(
+                SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=tray, spoolman_spool_id=spool_id)
+            )
+        await db_session.commit()
+        client = MagicMock()
+        client.merge_spool_extra = AsyncMock(side_effect=[RuntimeError("Spoolman down"), {}])
+
+        with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+            assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 1
+
+        assert client.merge_spool_extra.await_count == 2
+
+    def test_mapping_reads_the_extra_fields(self):
+        spool = {
+            "id": 5,
+            "filament": {"id": 1, "name": "PLA", "material": "PLA"},
+            "extra": {
+                "bambu_last_dried_at": json.dumps("2026-10-06T12:30:00"),
+                "bambu_last_dried_temp": json.dumps("55"),
+                "bambu_last_dried_hours": json.dumps("7.5"),
+            },
+        }
+        mapped = _map_spoolman_spool(spool)
+        assert mapped["last_dried_at"] == "2026-10-06T12:30:00"
+        assert mapped["last_dried_temp"] == 55
+        assert mapped["last_dried_hours"] == 7.5
+
+    def test_mapping_reads_cleared_and_absent_fields_as_unknown(self):
+        base = {"id": 5, "filament": {"id": 1, "name": "PLA", "material": "PLA"}}
+        cleared = {
+            **base,
+            "extra": {
+                "bambu_last_dried_at": json.dumps(""),
+                "bambu_last_dried_temp": json.dumps(""),
+                "bambu_last_dried_hours": json.dumps(""),
+            },
+        }
+        for spool in (base, cleared):
+            mapped = _map_spoolman_spool(spool)
+            assert mapped["last_dried_at"] is None
+            assert mapped["last_dried_temp"] is None
+            assert mapped["last_dried_hours"] is None

+ 40 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -3071,3 +3071,43 @@ class TestPerSpoolCoreWeight:
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert mock_spoolman_client.update_spool_full.call_args.kwargs["remaining_weight"] == 620.0
         assert mock_spoolman_client.update_spool_full.call_args.kwargs["remaining_weight"] == 620.0
+
+
+class TestSpoolmanLastDried:
+    """#2863 — a drying date set by hand round-trips through spool.extra."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hand_set_date_writes_utc_and_clears_temperature_and_hours(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        response = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42", json={"last_dried_at": "2026-10-06T14:30:00+02:00"}
+        )
+
+        assert response.status_code == 200
+        mock_spoolman_client.merge_spool_extra.assert_called_once_with(
+            42,
+            {
+                "bambu_last_dried_at": json.dumps("2026-10-06T12:30:00"),
+                "bambu_last_dried_temp": json.dumps(""),
+                "bambu_last_dried_hours": json.dumps(""),
+            },
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_null_clears_the_date(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"last_dried_at": None})
+
+        assert response.status_code == 200
+        fields = mock_spoolman_client.merge_spool_extra.call_args.args[1]
+        assert fields["bambu_last_dried_at"] == json.dumps("")

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

@@ -8446,3 +8446,110 @@ class TestAmsFilamentSettingRefusalLogging:
 
 
         assert self._refusals(caplog) == []
         assert self._refusals(caplog) == []
         assert "extrusion_cali_sel" not in caplog.text
         assert "extrusion_cali_sel" not in caplog.text
+
+
+class TestDryingCycleEndReport:
+    """#2863 — the cycle-end report carries what was seen of the cycle, so the
+    spools in that AMS can be stamped as dried."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        reports: list = []
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST-DRYING-END",
+            access_code="12345678",
+            on_drying_cycle_end=reports.append,
+        )
+        client._client = MagicMock()
+        client._cycle_reports = reports
+        return client
+
+    @staticmethod
+    def _push(client, dry_time, info=None):
+        unit = {"id": "0", "dry_time": dry_time, "tray": []}
+        if info is not None:
+            unit["info"] = info
+        client._handle_ams_data({"ams": [unit]})
+
+    def test_cycle_watched_from_its_start(self, mqtt_client):
+        self._push(mqtt_client, 0)
+        self._push(mqtt_client, 360)
+        self._push(mqtt_client, 200)
+        self._push(mqtt_client, 1)
+        self._push(mqtt_client, 0)
+
+        [report] = mqtt_client._cycle_reports
+        assert report.ams_id == 0
+        assert report.peak_minutes == 360
+        assert report.remaining_minutes == 1
+        assert report.start_seen is True
+        assert report.target_temp is None
+
+    def test_first_sighting_mid_cycle_is_not_the_start(self, mqtt_client):
+        """Bambuddy came up while the AMS was already drying."""
+        self._push(mqtt_client, 200)
+        self._push(mqtt_client, 0)
+
+        [report] = mqtt_client._cycle_reports
+        assert report.start_seen is False
+        assert report.peak_minutes == 200
+
+    def test_target_survives_a_stop_sent_by_bambuddy(self, mqtt_client):
+        """A stop drops _drying_targets before the countdown reaches 0; the
+        report must still carry the temperature the cycle ran at."""
+        self._push(mqtt_client, 0)
+        mqtt_client.send_drying_command(0, 55, 8, mode=1, filament="PLA")
+        self._push(mqtt_client, 480)
+        self._push(mqtt_client, 100)
+        mqtt_client.send_drying_command(0, 0, 0, mode=0)
+        self._push(mqtt_client, 0)
+
+        [report] = mqtt_client._cycle_reports
+        assert report.target_temp == 55
+        assert report.target_hours == 8
+        assert report.remaining_minutes == 100
+
+    def test_transient_zero_does_not_split_the_cycle(self, mqtt_client):
+        """#2759's 720 → 0 → 719 blip while Checking is one cycle, not two."""
+        self._push(mqtt_client, 0)
+        self._push(mqtt_client, 720, info="11402113")
+        self._push(mqtt_client, 0, info="11402113")
+        self._push(mqtt_client, 719, info="11402123")
+        self._push(mqtt_client, 0, info="11402103")
+
+        [report] = mqtt_client._cycle_reports
+        assert report.peak_minutes == 720
+        assert report.start_seen is True
+
+    def test_next_cycle_starts_fresh(self, mqtt_client):
+        self._push(mqtt_client, 0)
+        mqtt_client.send_drying_command(0, 55, 8, mode=1, filament="PLA")
+        self._push(mqtt_client, 480)
+        self._push(mqtt_client, 0)
+        # Started from the printer screen this time: no target of ours.
+        self._push(mqtt_client, 120)
+        self._push(mqtt_client, 0)
+
+        first, second = mqtt_client._cycle_reports
+        assert first.target_temp == 55
+        assert second.target_temp is None
+        assert second.peak_minutes == 120
+
+    def test_new_start_during_a_live_cycle_begins_a_fresh_record(self, mqtt_client):
+        """A start sent while a cycle still counts down replaces it without
+        dry_time passing through 0; the report is about the new run."""
+        self._push(mqtt_client, 0)
+        mqtt_client.send_drying_command(0, 55, 8, mode=1, filament="PLA")
+        self._push(mqtt_client, 480)
+        self._push(mqtt_client, 400)
+        mqtt_client.send_drying_command(0, 65, 2, mode=1, filament="PETG")
+        self._push(mqtt_client, 120)
+        self._push(mqtt_client, 0)
+
+        [report] = mqtt_client._cycle_reports
+        assert report.target_temp == 65
+        assert report.target_hours == 2
+        assert report.peak_minutes == 120

+ 19 - 0
backend/tests/unit/test_github_backup_spool_fields.py

@@ -45,3 +45,22 @@ async def test_location_id_is_left_out(db_session):
     await GitHubBackupService()._collect_spools(db_session, files)
     await GitHubBackupService()._collect_spools(db_session, files)
 
 
     assert "location_id" not in files["spools/inventory.json"]["spools"][0]
     assert "location_id" not in files["spools/inventory.json"]["spools"][0]
+
+
+@pytest.mark.asyncio
+async def test_collects_the_drying_record(db_session):
+    """#2863 — written with str(), the form _parse_dt reads back."""
+    from datetime import datetime
+
+    db_session.add(
+        Spool(material="PLA", last_dried_at=datetime(2026, 10, 6, 12, 30), last_dried_temp=55, last_dried_hours=7.5)
+    )
+    await db_session.commit()
+
+    files: dict = {}
+    await GitHubBackupService()._collect_spools(db_session, files)
+
+    entry = files["spools/inventory.json"]["spools"][0]
+    assert entry["last_dried_at"] == "2026-10-06 12:30:00"
+    assert entry["last_dried_temp"] == 55
+    assert entry["last_dried_hours"] == 7.5

+ 30 - 0
backend/tests/unit/test_github_restore.py

@@ -855,6 +855,36 @@ class TestRestoreSpools:
         assert restored.id != 1
         assert restored.id != 1
         assert restored.material == "PLA"
         assert restored.material == "PLA"
 
 
+    @pytest.mark.asyncio
+    async def test_restores_the_drying_record(self, db_session):
+        """#2863 — the date the backup writes with str() parses back."""
+        tally = _CategoryTally()
+        entry = self._spool_entry(last_dried_at="2026-10-06 12:30:00", last_dried_temp=55, last_dried_hours=7.5)
+
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        restored = (await db_session.execute(select(Spool))).scalars().one()
+        assert restored.last_dried_at == datetime(2026, 10, 6, 12, 30)
+        assert restored.last_dried_temp == 55
+        assert restored.last_dried_hours == 7.5
+
+    @pytest.mark.asyncio
+    async def test_older_backup_keeps_the_live_drying_record(self, db_session):
+        """A backup written before #2863 has no drying keys and must not wipe them."""
+        db_session.add(
+            Spool(material="PLA", tag_uid="AABBCCDD", last_dried_at=datetime(2026, 9, 1, 8, 0), last_dried_temp=55)
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
+        await db_session.commit()
+
+        spool = (await db_session.execute(select(Spool))).scalars().one()
+        assert spool.last_dried_at == datetime(2026, 9, 1, 8, 0)
+        assert spool.last_dried_temp == 55
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_matches_existing_spool_by_tag_uid(self, db_session):
     async def test_matches_existing_spool_by_tag_uid(self, db_session):
         db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
         db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))

+ 72 - 0
backend/tests/unit/test_spool_drying_record_2863.py

@@ -0,0 +1,72 @@
+"""Which drying cycles mark their spools as dried, and for how long (#2863)."""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import DryingCycleEnd
+from backend.app.services.spool_drying import drying_record
+
+
+def _cycle(remaining: int, peak: int, *, start_seen: bool = True, temp: int | None = None, hours: int | None = None):
+    return DryingCycleEnd(
+        ams_id=0,
+        remaining_minutes=remaining,
+        peak_minutes=peak,
+        start_seen=start_seen,
+        target_temp=temp,
+        target_hours=hours,
+    )
+
+
+def test_full_cycle_started_by_bambuddy():
+    record = drying_record(_cycle(1, 480, temp=55, hours=8))
+    assert record is not None
+    assert record.temp == 55
+    assert record.hours == 8.0
+
+
+@pytest.mark.parametrize(
+    ("remaining", "counts"),
+    [
+        (240, True),  # exactly half of 8 hours ran
+        (241, False),  # one minute short of half
+        (470, False),  # stopped ten minutes in
+    ],
+)
+def test_half_the_length_must_have_run(remaining, counts):
+    assert (drying_record(_cycle(remaining, 480, hours=8)) is not None) is counts
+
+
+def test_hours_are_the_time_actually_spent():
+    record = drying_record(_cycle(180, 480, temp=55, hours=8))
+    assert record is not None
+    assert record.hours == 5.0
+
+
+def test_requested_length_wins_over_a_late_peak():
+    """Bambuddy started the cycle but its first countdown push arrived late."""
+    record = drying_record(_cycle(1, 300, hours=8))
+    assert record is not None
+    assert record.hours == 8.0
+
+
+def test_cycle_started_on_the_printer_keeps_its_hours():
+    """Watched from its first minute, so the peak is the real length."""
+    record = drying_record(_cycle(0, 360))
+    assert record is not None
+    assert record.temp is None
+    assert record.hours == 6.0
+
+
+def test_unwatched_start_leaves_hours_unknown():
+    """Bambuddy came up mid-cycle: the peak understates the length."""
+    record = drying_record(_cycle(2, 200, start_seen=False))
+    assert record is not None
+    assert record.hours is None
+
+
+def test_unwatched_cycle_that_ran_to_term_counts_even_with_one_sighting():
+    assert drying_record(_cycle(3, 3, start_seen=False)) is not None
+
+
+def test_unwatched_cycle_stopped_early_does_not_count():
+    assert drying_record(_cycle(150, 200, start_seen=False)) is None

+ 174 - 0
frontend/src/__tests__/components/SpoolFormLastDried.test.tsx

@@ -0,0 +1,174 @@
+/**
+ * The "Last dried" field in the spool form (#2863).
+ *
+ * An AMS drying run stamps the date in the background, so the form must only
+ * send it when the user changed it — otherwise saving an unrelated edit would
+ * put back the date the form loaded with.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolFormModal } from '../../components/SpoolFormModal';
+import type { InventorySpool } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }),
+    orcaCloudListProfiles: vi.fn().mockResolvedValue({ filament: [] }),
+    getFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolCatalog: vi.fn().mockResolvedValue([]),
+    getLocations: vi.fn().mockResolvedValue([]),
+    // Elegoo is only known for PLA here — the pairing that used to hide it
+    // from the brand list as soon as ASA was selected.
+    getColorCatalog: vi.fn().mockResolvedValue([
+      { manufacturer: 'Elegoo', color_name: 'Red', hex_color: 'FF0000', material: 'PLA' },
+      { manufacturer: 'Polymaker', color_name: 'Blue', hex_color: '0000FF', material: 'ASA' },
+    ]),
+    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
+    getBuiltinFilaments: vi.fn().mockResolvedValue([
+      { filament_id: 'GFA05', name: 'Generic ASA' },
+    ]),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue(null),
+    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
+    updateSpool: vi.fn().mockResolvedValue({ id: 7 }),
+    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
+    getAssignments: vi.fn().mockResolvedValue([]),
+    unassignSpool: vi.fn().mockResolvedValue({}),
+  },
+  ApiError: class ApiError extends Error {
+    status: number;
+    constructor(message: string, status: number) {
+      super(message);
+      this.status = status;
+    }
+  },
+}));
+
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+import { api } from '../../api/client';
+
+const driedSpool: InventorySpool = {
+  id: 7,
+  material: 'ASA',
+  subtype: null,
+  brand: null,
+  color_name: null,
+  rgba: '808080FF',
+  extra_colors: null,
+  effect_type: null,
+  label_weight: 1000,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  weight_used: 0,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  last_dried_at: '2026-09-01T12:00:00',
+  last_dried_temp: 55,
+  last_dried_hours: 8,
+} as unknown as InventorySpool;
+
+async function openAdditional(mode: 'edit' | 'copy' | 'create' = 'edit') {
+  render(
+    <SpoolFormModal
+      isOpen={true}
+      onClose={vi.fn()}
+      spool={mode === 'create' ? undefined : driedSpool}
+      mode={mode}
+      currencySymbol="$"
+    />,
+  );
+  await waitFor(() => expect(screen.getAllByRole('heading').length).toBeGreaterThan(0));
+  fireEvent.click(screen.getByRole('button', { name: /Color & Cost/ }));
+}
+
+describe('SpoolFormModal last dried (#2863)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('loads the stored date into the field', async () => {
+    await openAdditional();
+    const input = await screen.findByLabelText('Last dried');
+    const expected = new Date('2026-09-01T12:00:00Z');
+    const pad = (n: number) => String(n).padStart(2, '0');
+    expect(input).toHaveValue(
+      `${expected.getFullYear()}-${pad(expected.getMonth() + 1)}-${pad(expected.getDate())}T${pad(expected.getHours())}:${pad(expected.getMinutes())}`,
+    );
+  });
+
+  it('does not send the date when it was not changed', async () => {
+    await openAdditional();
+    await screen.findByLabelText('Last dried');
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
+    const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
+    expect(payload).not.toHaveProperty('last_dried_at');
+  });
+
+  it('sends the time "Now" sets, as UTC', async () => {
+    await openAdditional();
+    await screen.findByLabelText('Last dried');
+    const before = Date.now();
+    fireEvent.click(screen.getByRole('button', { name: 'Now' }));
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
+    const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
+    const sent = new Date(payload.last_dried_at as string).getTime();
+    expect(payload.last_dried_at as string).toMatch(/Z$/);
+    // datetime-local has minute precision.
+    expect(Math.abs(sent - before)).toBeLessThan(61_000);
+  });
+
+  it('sends null when the field is cleared', async () => {
+    await openAdditional();
+    const input = await screen.findByLabelText('Last dried');
+    fireEvent.change(input, { target: { value: '' } });
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
+    const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
+    expect(payload.last_dried_at).toBeNull();
+  });
+
+  it('is not offered when copying a spool', async () => {
+    await openAdditional('copy');
+    await screen.findByText('Note');
+    expect(screen.queryByLabelText('Last dried')).not.toBeInTheDocument();
+  });
+});

+ 7 - 1
frontend/src/__tests__/pages/InventoryPageGrouping.test.ts

@@ -17,7 +17,7 @@ import { aggregateGroupSpool } from '../../utils/inventoryGrouping';
 // and effect_type are part of the key (#1154) so multi-colour / effect
 // and effect_type are part of the key (#1154) so multi-colour / effect
 // variants don't get collapsed under "Group similar".
 // variants don't get collapsed under "Group similar".
 function spoolGroupKey(s: InventorySpool): string {
 function spoolGroupKey(s: InventorySpool): string {
-  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
+  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}|${s.last_dried_at || ''}`;
 }
 }
 
 
 type DisplayItem =
 type DisplayItem =
@@ -104,6 +104,12 @@ describe('spoolGroupKey', () => {
     expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
     expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
   });
   });
 
 
+  it('keeps a dried spool out of a group with an undried one (#2863)', () => {
+    const a = makeSpool({ id: 1, last_dried_at: '2026-10-06T12:30:00' });
+    const b = makeSpool({ id: 2 });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
   it('generates different key when material differs', () => {
   it('generates different key when material differs', () => {
     const a = makeSpool({ id: 1, material: 'PLA' });
     const a = makeSpool({ id: 1, material: 'PLA' });
     const b = makeSpool({ id: 2, material: 'PETG' });
     const b = makeSpool({ id: 2, material: 'PETG' });

+ 105 - 0
frontend/src/__tests__/pages/InventoryPageLastDried.test.tsx

@@ -0,0 +1,105 @@
+/**
+ * The "Last dried" column (#2863): shown by default, with the temperature and
+ * hours of the run when they are known.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const BASE = {
+  material: 'PLA',
+  subtype: 'Basic',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 100,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: 220,
+  nozzle_temp_max: 240,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  storage_location: null,
+  category: null,
+  low_stock_threshold_pct: null,
+  weight_locked: false,
+};
+
+const SPOOLS = [
+  { ...BASE, id: 1, brand: 'AlphaBrand', last_dried_at: '2026-09-01T12:00:00', last_dried_temp: 55, last_dried_hours: 7.5 },
+  { ...BASE, id: 2, brand: 'BetaBrand', last_dried_at: '2026-09-02T12:00:00', last_dried_temp: null, last_dried_hours: null },
+  { ...BASE, id: 3, brand: 'GammaBrand', last_dried_at: null },
+];
+
+function setupHandlers() {
+  server.use(
+    http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD', low_stock_threshold: 20.0, language: 'en' })),
+    http.get('/api/v1/settings/spoolman', () =>
+      HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' })
+    ),
+    http.get('/api/v1/inventory/spools', () => HttpResponse.json(SPOOLS)),
+    http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/colors', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/locations', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+  );
+}
+
+function row(brand: string): HTMLElement {
+  const found = Array.from(document.querySelectorAll('tbody tr')).find((r) => r.textContent?.includes(brand));
+  if (!found) throw new Error(`no row for ${brand}`);
+  return found as HTMLElement;
+}
+
+describe('InventoryPage last dried column', () => {
+  beforeEach(() => {
+    setupHandlers();
+    vi.mocked(localStorage.getItem).mockReturnValue(null);
+  });
+
+  it('is visible by default', async () => {
+    render(<InventoryPageRouter />);
+    await waitFor(() => expect(screen.getByRole('columnheader', { name: /Last dried/ })).toBeInTheDocument());
+  });
+
+  it('shows the temperature and hours of the run when known', async () => {
+    render(<InventoryPageRouter />);
+    await waitFor(() => expect(row('AlphaBrand')).toHaveTextContent('55°C · 7.5h'));
+    expect(row('BetaBrand')).not.toHaveTextContent('°C');
+  });
+
+  it('sorts by the date', async () => {
+    vi.mocked(localStorage.getItem).mockImplementation((key) =>
+      key === 'bambuddy-inventory-sort' ? '{"column":"last_dried","direction":"desc"}' : null,
+    );
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => {
+      const brands = Array.from(document.querySelectorAll('tbody tr')).map((r) => r.textContent ?? '');
+      expect(brands.findIndex((t) => t.includes('BetaBrand'))).toBeLessThan(
+        brands.findIndex((t) => t.includes('AlphaBrand')),
+      );
+    });
+  });
+});

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

@@ -3850,6 +3850,11 @@ export interface InventorySpool {
   note: string | null;
   note: string | null;
   added_full: boolean | null;
   added_full: boolean | null;
   last_used: string | null;
   last_used: string | null;
+  // Last drying (#2863): stamped when an AMS drying run of at least half its
+  // length ends, or set by hand. Temperature and hours are null when unknown.
+  last_dried_at?: string | null;
+  last_dried_temp?: number | null;
+  last_dried_hours?: number | null;
   encode_time: string | null;
   encode_time: string | null;
   tag_uid: string | null;
   tag_uid: string | null;
   tray_uuid: string | null;
   tray_uuid: string | null;

+ 15 - 0
frontend/src/components/SpoolFormModal.tsx

@@ -24,6 +24,7 @@ import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSe
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
 import { normaliseFlow } from '../utils/nozzleFlow';
 import { normaliseFlow } from '../utils/nozzleFlow';
+import { parseUTCDate, toDateTimeLocalValue } from '../utils/date';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
 import { AssignToAmsModal } from './spoolbuddy/AssignToAmsModal';
 import { AssignToAmsModal } from './spoolbuddy/AssignToAmsModal';
 import {
 import {
@@ -83,6 +84,9 @@ export function SpoolFormModal({
   // through core_weight.
   // through core_weight.
   const [coreWeightTouched, setCoreWeightTouched] = useState(false);
   const [coreWeightTouched, setCoreWeightTouched] = useState(false);
   const [locationIdTouched, setLocationIdTouched] = useState(false);
   const [locationIdTouched, setLocationIdTouched] = useState(false);
+  // Sent only when changed, so saving an unrelated edit cannot put back a
+  // drying date that an AMS run stamped while the form was open (#2863).
+  const [lastDriedTouched, setLastDriedTouched] = useState(false);
   // Supplier assignments (#2988). Held outside SpoolFormData — they are
   // Supplier assignments (#2988). Held outside SpoolFormData — they are
   // relational and saved through their own replace-all endpoint. An untouched
   // relational and saved through their own replace-all endpoint. An untouched
   // create does not send them, so it keeps the backend's inherited
   // create does not send them, so it keeps the backend's inherited
@@ -430,6 +434,10 @@ export function SpoolFormModal({
           low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
           low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
           material_number: spool.material_number || '',
           material_number: spool.material_number || '',
           location_id: spool.location_id ?? null,
           location_id: spool.location_id ?? null,
+          last_dried_at: (() => {
+            const dried = isCopying ? null : parseUTCDate(spool.last_dried_at);
+            return dried ? toDateTimeLocalValue(dried) : '';
+          })(),
           spoolman_filament_id: null,
           spoolman_filament_id: null,
         });
         });
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
@@ -494,6 +502,7 @@ export function SpoolFormModal({
       // save) A's per-model overrides on B. Refilled by the fetch below.
       // save) A's per-model overrides on B. Refilled by the fetch below.
       setModelPresets(new Map());
       setModelPresets(new Map());
       setWeightTouched(false);
       setWeightTouched(false);
+      setLastDriedTouched(false);
       // A copy of a Spoolman spool with its own tare carries that tare, as it
       // A copy of a Spoolman spool with its own tare carries that tare, as it
       // would any other field shown in the form; one that inherits keeps
       // would any other field shown in the form; one that inherits keeps
       // inheriting. Only Spoolman spools report the flag (#2908).
       // inheriting. Only Spoolman spools report the flag (#2908).
@@ -566,6 +575,7 @@ export function SpoolFormModal({
     if (key === 'weight_used') setWeightTouched(true);
     if (key === 'weight_used') setWeightTouched(true);
     if (key === 'core_weight') setCoreWeightTouched(true);
     if (key === 'core_weight') setCoreWeightTouched(true);
     if (key === 'location_id') setLocationIdTouched(true);
     if (key === 'location_id') setLocationIdTouched(true);
+    if (key === 'last_dried_at') setLastDriedTouched(true);
     if (errors[key]) {
     if (errors[key]) {
       setErrors(prev => ({ ...prev, [key]: undefined }));
       setErrors(prev => ({ ...prev, [key]: undefined }));
     }
     }
@@ -995,6 +1005,10 @@ export function SpoolFormModal({
       data.location_id = formData.location_id;
       data.location_id = formData.location_id;
     }
     }
 
 
+    if (isEditing && lastDriedTouched) {
+      data.last_dried_at = formData.last_dried_at ? new Date(formData.last_dried_at).toISOString() : null;
+    }
+
     if (isEditing) {
     if (isEditing) {
       updateMutation.mutate(data);
       updateMutation.mutate(data);
     } else if (quantity > 1) {
     } else if (quantity > 1) {
@@ -1188,6 +1202,7 @@ export function SpoolFormModal({
                   availableCategories={availableCategories}
                   availableCategories={availableCategories}
                   availableMaterialNumbers={availableMaterialNumbers}
                   availableMaterialNumbers={availableMaterialNumbers}
                   availableLocations={storageLocations}
                   availableLocations={storageLocations}
+                  showLastDried={isEditing}
                   onCreateLocation={async (name) => {
                   onCreateLocation={async (name) => {
                     try {
                     try {
                       const created = await api.createLocation({ name });
                       const created = await api.createLocation({ name });

+ 29 - 0
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useMemo } from 'react';
 import { Scale } from 'lucide-react';
 import { Scale } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useToast } from '../../contexts/ToastContext';
 import { useToast } from '../../contexts/ToastContext';
+import { toDateTimeLocalValue } from '../../utils/date';
 import type { AdditionalSectionProps } from './types';
 import type { AdditionalSectionProps } from './types';
 
 
 function SpoolWeightPicker({
 function SpoolWeightPicker({
@@ -179,6 +180,7 @@ export function AdditionalSection({
   onCreateLocation,
   onCreateLocation,
   globalLowStockThreshold,
   globalLowStockThreshold,
   spoolmanMode = false,
   spoolmanMode = false,
+  showLastDried = false,
 }: AdditionalSectionProps) {
 }: AdditionalSectionProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { showToast } = useToast();
   const { showToast } = useToast();
@@ -464,6 +466,33 @@ export function AdditionalSection({
         )}
         )}
       </div>
       </div>
 
 
+      {/* Last dried (#2863). Stamped by AMS drying runs; set here for a
+          spool dried in an external dryer. */}
+      {showLastDried && (
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-last-dried">
+            {t('inventory.lastDried')}
+          </label>
+          <div className="flex gap-2">
+            <input
+              id="spool-last-dried"
+              type="datetime-local"
+              className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+              value={formData.last_dried_at}
+              onChange={(e) => updateField('last_dried_at', e.target.value)}
+            />
+            <button
+              type="button"
+              className="px-3 py-2 text-sm rounded-lg bg-bambu-dark-tertiary text-white hover:bg-bambu-gray-dark"
+              onClick={() => updateField('last_dried_at', toDateTimeLocalValue(new Date()))}
+            >
+              {t('inventory.lastDriedNow')}
+            </button>
+          </div>
+          <p className="text-xs text-bambu-gray mt-1">{t('inventory.lastDriedHelp')}</p>
+        </div>
+      )}
+
       {/* Note */}
       {/* Note */}
       <div className="sm:col-span-2">
       <div className="sm:col-span-2">
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>

+ 7 - 0
frontend/src/components/spool-form/types.ts

@@ -45,6 +45,9 @@ export interface SpoolFormData {
   // shared by all spools of the same product. Free text.
   // shared by all spools of the same product. Free text.
   material_number: string;
   material_number: string;
   location_id: number | null;
   location_id: number | null;
+  // Last drying (#2863), as a datetime-local value in the browser's time zone.
+  // Empty means never dried. Only shown and sent when editing.
+  last_dried_at: string;
   // When set the spool is linked to a specific Spoolman filament catalog entry;
   // When set the spool is linked to a specific Spoolman filament catalog entry;
   // the backend skips find_or_create_filament() and uses this ID directly.
   // the backend skips find_or_create_filament() and uses this ID directly.
   spoolman_filament_id: number | null;
   spoolman_filament_id: number | null;
@@ -69,6 +72,7 @@ export const defaultFormData: SpoolFormData = {
   low_stock_threshold_pct: null,
   low_stock_threshold_pct: null,
   material_number: '',
   material_number: '',
   location_id: null,
   location_id: null,
+  last_dried_at: '',
   spoolman_filament_id: null,
   spoolman_filament_id: null,
 };
 };
 
 
@@ -219,6 +223,9 @@ export interface AdditionalSectionProps extends SectionProps {
   globalLowStockThreshold: number;
   globalLowStockThreshold: number;
   availableLocations?: { id: number; name: string }[];
   availableLocations?: { id: number; name: string }[];
   onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
   onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
+  // When true the "Last dried" field is shown. Edit mode only: a new spool
+  // has no drying record to correct (#2863).
+  showLastDried?: boolean;
   // When true the material number input is hidden: in Spoolman mode the
   // When true the material number input is hidden: in Spoolman mode the
   // number is Spoolman's filament-level article_number, maintained in
   // number is Spoolman's filament-level article_number, maintained in
   // Spoolman itself and shown read-only in the list (#2870).
   // Spoolman itself and shown read-only in the list (#2870).

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

@@ -5135,6 +5135,9 @@ export default {
     materialNumber: 'Material-Nr.',
     materialNumber: 'Material-Nr.',
     materialNumberPlaceholder: 'z. B. 15',
     materialNumberPlaceholder: 'z. B. 15',
     materialNumberHelp: 'Interne Einkaufsnummer - wird von allen Spulen dieses Produkts geteilt. Neue Spulen desselben Produkts übernehmen sie.',
     materialNumberHelp: 'Interne Einkaufsnummer - wird von allen Spulen dieses Produkts geteilt. Neue Spulen desselben Produkts übernehmen sie.',
+    lastDried: 'Zuletzt getrocknet',
+    lastDriedNow: 'Jetzt',
+    lastDriedHelp: 'Wird eingetragen, wenn ein AMS-Trocknungslauf mindestens zur Hälfte gelaufen ist. Für eine anderswo getrocknete Spule hier setzen.',
     materialNumberNone: 'Keine Materialnummer',
     materialNumberNone: 'Keine Materialnummer',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
     suppliers: {

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

@@ -5177,6 +5177,9 @@ export default {
     materialNumber: 'Material No.',
     materialNumber: 'Material No.',
     materialNumberPlaceholder: 'e.g. 15',
     materialNumberPlaceholder: 'e.g. 15',
     materialNumberHelp: 'Internal purchasing number — shared by all spools of this product. New spools of the same product inherit it.',
     materialNumberHelp: 'Internal purchasing number — shared by all spools of this product. New spools of the same product inherit it.',
+    lastDried: 'Last dried',
+    lastDriedNow: 'Now',
+    lastDriedHelp: 'Filled in when an AMS drying run of at least half its length ends. Set it here for a spool dried elsewhere.',
     materialNumberNone: 'No material number',
     materialNumberNone: 'No material number',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
     suppliers: {

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

@@ -5138,6 +5138,9 @@ export default {
     materialNumber: 'N.º de material',
     materialNumber: 'N.º de material',
     materialNumberPlaceholder: 'p. ej. 15',
     materialNumberPlaceholder: 'p. ej. 15',
     materialNumberHelp: 'Número interno de compras: compartido por todas las bobinas de este producto. Las bobinas nuevas del mismo producto lo heredan.',
     materialNumberHelp: 'Número interno de compras: compartido por todas las bobinas de este producto. Las bobinas nuevas del mismo producto lo heredan.',
+    lastDried: 'Último secado',
+    lastDriedNow: 'Ahora',
+    lastDriedHelp: 'Se rellena cuando termina un secado en el AMS que ha durado al menos la mitad de su duración. Indícalo aquí para una bobina secada en otro sitio.',
     materialNumberNone: 'Sin número de material',
     materialNumberNone: 'Sin número de material',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
     suppliers: {

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

@@ -5123,6 +5123,9 @@ export default {
     materialNumber: 'N° matière',
     materialNumber: 'N° matière',
     materialNumberPlaceholder: 'ex. 15',
     materialNumberPlaceholder: 'ex. 15',
     materialNumberHelp: 'Numéro d\'achat interne - partagé par toutes les bobines de ce produit. Les nouvelles bobines du même produit en héritent.',
     materialNumberHelp: 'Numéro d\'achat interne - partagé par toutes les bobines de ce produit. Les nouvelles bobines du même produit en héritent.',
+    lastDried: 'Dernier séchage',
+    lastDriedNow: 'Maintenant',
+    lastDriedHelp: 'Renseigné à la fin d\'un séchage AMS ayant duré au moins la moitié de sa durée. Indiquez-le ici pour une bobine séchée ailleurs.',
     materialNumberNone: 'Aucun numéro de matière',
     materialNumberNone: 'Aucun numéro de matière',
     suppliers: {
     suppliers: {
       label: 'Fournisseurs',
       label: 'Fournisseurs',

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: 'N. materiale',
     materialNumber: 'N. materiale',
     materialNumberPlaceholder: 'es. 15',
     materialNumberPlaceholder: 'es. 15',
     materialNumberHelp: 'Numero interno di acquisto - condiviso da tutte le bobine di questo prodotto. Le nuove bobine dello stesso prodotto lo ereditano.',
     materialNumberHelp: 'Numero interno di acquisto - condiviso da tutte le bobine di questo prodotto. Le nuove bobine dello stesso prodotto lo ereditano.',
+    lastDried: 'Ultima essiccazione',
+    lastDriedNow: 'Ora',
+    lastDriedHelp: 'Compilato quando termina un\'essiccazione AMS durata almeno metà del tempo previsto. Impostalo qui per una bobina essiccata altrove.',
     materialNumberNone: 'Nessun numero materiale',
     materialNumberNone: 'Nessun numero materiale',
     suppliers: {
     suppliers: {
       label: 'Fornitori',
       label: 'Fornitori',

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

@@ -5135,6 +5135,9 @@ export default {
     materialNumber: '資材番号',
     materialNumber: '資材番号',
     materialNumberPlaceholder: '例:15',
     materialNumberPlaceholder: '例:15',
     materialNumberHelp: '社内の購買番号です。同じ製品のすべてのスプールで共有され、同じ製品の新しいスプールに自動的に引き継がれます。',
     materialNumberHelp: '社内の購買番号です。同じ製品のすべてのスプールで共有され、同じ製品の新しいスプールに自動的に引き継がれます。',
+    lastDried: '最終乾燥',
+    lastDriedNow: '今',
+    lastDriedHelp: 'AMS の乾燥が予定時間の半分以上行われて終了すると記録されます。他の場所で乾燥したスプールはここで設定してください。',
     materialNumberNone: '資材番号なし',
     materialNumberNone: '資材番号なし',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
     suppliers: {

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

@@ -4920,6 +4920,9 @@ export default {
     materialNumber: '자재 번호',
     materialNumber: '자재 번호',
     materialNumberPlaceholder: '예: 15',
     materialNumberPlaceholder: '예: 15',
     materialNumberHelp: '내부 구매 번호 - 이 제품의 모든 스풀이 공유합니다. 같은 제품의 새 스풀은 이 번호를 이어받습니다.',
     materialNumberHelp: '내부 구매 번호 - 이 제품의 모든 스풀이 공유합니다. 같은 제품의 새 스풀은 이 번호를 이어받습니다.',
+    lastDried: '마지막 건조',
+    lastDriedNow: '지금',
+    lastDriedHelp: 'AMS 건조가 예정 시간의 절반 이상 진행된 뒤 끝나면 기록됩니다. 다른 곳에서 건조한 스풀은 여기에서 설정하세요.',
     materialNumberNone: '자재 번호 없음',
     materialNumberNone: '자재 번호 없음',
     suppliers: {
     suppliers: {
       label: '공급업체',
       label: '공급업체',

+ 3 - 0
frontend/src/i18n/locales/nl.ts

@@ -5175,6 +5175,9 @@ export default {
     materialNumber: 'Materiaalnr.',
     materialNumber: 'Materiaalnr.',
     materialNumberPlaceholder: 'bijv. 15',
     materialNumberPlaceholder: 'bijv. 15',
     materialNumberHelp: 'Intern inkoopnummer - gedeeld door alle spoelen van dit product. Nieuwe spoelen van hetzelfde product nemen het over.',
     materialNumberHelp: 'Intern inkoopnummer - gedeeld door alle spoelen van dit product. Nieuwe spoelen van hetzelfde product nemen het over.',
+    lastDried: 'Laatst gedroogd',
+    lastDriedNow: 'Nu',
+    lastDriedHelp: 'Wordt ingevuld wanneer een AMS-droogcyclus van minstens de helft van zijn duur eindigt. Stel het hier in voor een elders gedroogde spoel.',
     materialNumberNone: 'Geen materiaalnummer',
     materialNumberNone: 'Geen materiaalnummer',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
     suppliers: {

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: 'Nº do Material',
     materialNumber: 'Nº do Material',
     materialNumberPlaceholder: 'ex. 15',
     materialNumberPlaceholder: 'ex. 15',
     materialNumberHelp: 'Número interno de compra - compartilhado por todos os carretéis deste produto. Novos carretéis do mesmo produto o herdam.',
     materialNumberHelp: 'Número interno de compra - compartilhado por todos os carretéis deste produto. Novos carretéis do mesmo produto o herdam.',
+    lastDried: 'Última secagem',
+    lastDriedNow: 'Agora',
+    lastDriedHelp: 'Preenchido quando termina uma secagem no AMS que durou pelo menos metade do tempo previsto. Defina aqui para um carretel secado em outro lugar.',
     materialNumberNone: 'Sem número de material',
     materialNumberNone: 'Sem número de material',
     suppliers: {
     suppliers: {
       label: 'Fornecedores',
       label: 'Fornecedores',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -4911,6 +4911,9 @@ export default {
     materialNumber: 'Артикул',
     materialNumber: 'Артикул',
     materialNumberPlaceholder: 'напр. 15',
     materialNumberPlaceholder: 'напр. 15',
     materialNumberHelp: 'Внутренний закупочный номер — общий для всех катушек этого товара. Новые катушки того же товара наследуют его.',
     materialNumberHelp: 'Внутренний закупочный номер — общий для всех катушек этого товара. Новые катушки того же товара наследуют его.',
+    lastDried: 'Последняя сушка',
+    lastDriedNow: 'Сейчас',
+    lastDriedHelp: 'Заполняется, когда сушка в AMS завершается, проработав не меньше половины заданного времени. Для катушки, высушенной в другом месте, укажите здесь.',
     materialNumberNone: 'Без артикула',
     materialNumberNone: 'Без артикула',
     suppliers: {
     suppliers: {
       label: "Поставщики",
       label: "Поставщики",

+ 3 - 0
frontend/src/i18n/locales/sv.ts

@@ -5174,6 +5174,9 @@ errors: {
     materialNumber: 'Materialnr',
     materialNumber: 'Materialnr',
     materialNumberPlaceholder: 't.ex. 15',
     materialNumberPlaceholder: 't.ex. 15',
     materialNumberHelp: 'Internt inköpsnummer — delas av alla rullar av denna produkt. Nya rullar av samma produkt ärver det.',
     materialNumberHelp: 'Internt inköpsnummer — delas av alla rullar av denna produkt. Nya rullar av samma produkt ärver det.',
+    lastDried: 'Senast torkad',
+    lastDriedNow: 'Nu',
+    lastDriedHelp: 'Fylls i när en AMS-torkning som pågått minst halva sin tid avslutas. Ange det här för en rulle som torkats någon annanstans.',
     materialNumberNone: 'Inget materialnummer',
     materialNumberNone: 'Inget materialnummer',
     suppliers: {
     suppliers: {
       label: 'Leverantörer',
       label: 'Leverantörer',

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: 'Malzeme No.',
     materialNumber: 'Malzeme No.',
     materialNumberPlaceholder: 'örn. 15',
     materialNumberPlaceholder: 'örn. 15',
     materialNumberHelp: 'Dahili satın alma numarası - bu ürünün tüm makaraları tarafından paylaşılır. Aynı ürünün yeni makaraları bu numarayı devralır.',
     materialNumberHelp: 'Dahili satın alma numarası - bu ürünün tüm makaraları tarafından paylaşılır. Aynı ürünün yeni makaraları bu numarayı devralır.',
+    lastDried: 'Son kurutma',
+    lastDriedNow: 'Şimdi',
+    lastDriedHelp: 'Süresinin en az yarısı kadar çalışan bir AMS kurutması bittiğinde doldurulur. Başka yerde kurutulan bir makara için buradan ayarlayın.',
     materialNumberNone: 'Malzeme numarası yok',
     materialNumberNone: 'Malzeme numarası yok',
     suppliers: {
     suppliers: {
       label: 'Tedarikçiler',
       label: 'Tedarikçiler',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -5171,6 +5171,9 @@ export default {
     materialNumber: 'Мат. №',
     materialNumber: 'Мат. №',
     materialNumberPlaceholder: 'напр. 15',
     materialNumberPlaceholder: 'напр. 15',
     materialNumberHelp: 'Внутрішній закупівельний номер — спільний для всіх котушок цього продукту. Нові котушки того самого продукту успадковують його.',
     materialNumberHelp: 'Внутрішній закупівельний номер — спільний для всіх котушок цього продукту. Нові котушки того самого продукту успадковують його.',
+    lastDried: 'Остання сушка',
+    lastDriedNow: 'Зараз',
+    lastDriedHelp: 'Заповнюється, коли сушіння в AMS завершується, пропрацювавши щонайменше половину заданого часу. Для котушки, висушеної деінде, вкажіть тут.',
     materialNumberNone: 'Без номера матеріалу',
     materialNumberNone: 'Без номера матеріалу',
     suppliers: {
     suppliers: {
       label: "Постачальники",
       label: "Постачальники",

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: '物料号',
     materialNumber: '物料号',
     materialNumberPlaceholder: '例如 15',
     materialNumberPlaceholder: '例如 15',
     materialNumberHelp: '内部采购编号 — 同一产品的所有料盘共用。同一产品的新料盘会自动继承。',
     materialNumberHelp: '内部采购编号 — 同一产品的所有料盘共用。同一产品的新料盘会自动继承。',
+    lastDried: '上次烘干',
+    lastDriedNow: '现在',
+    lastDriedHelp: 'AMS 烘干运行至少一半时长后结束时自动填写。在其他地方烘干的料盘请在此设置。',
     materialNumberNone: '无物料号',
     materialNumberNone: '无物料号',
     suppliers: {
     suppliers: {
       label: '供应商',
       label: '供应商',

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: '料號',
     materialNumber: '料號',
     materialNumberPlaceholder: '例如:15',
     materialNumberPlaceholder: '例如:15',
     materialNumberHelp: '內部採購編號 — 同一產品的所有料盤共用。同一產品的新料盤會自動繼承。',
     materialNumberHelp: '內部採購編號 — 同一產品的所有料盤共用。同一產品的新料盤會自動繼承。',
+    lastDried: '上次烘乾',
+    lastDriedNow: '現在',
+    lastDriedHelp: 'AMS 烘乾執行至少一半時長後結束時自動填寫。在其他地方烘乾的料盤請在此設定。',
     materialNumberNone: '無料號',
     materialNumberNone: '無料號',
     suppliers: {
     suppliers: {
       label: '供應商',
       label: '供應商',

+ 37 - 1
frontend/src/pages/InventoryPage.tsx

@@ -69,7 +69,9 @@ function spoolGroupKey(s: InventorySpool): string {
   // Include extra_colors + effect_type so the "Group similar" toggle does
   // Include extra_colors + effect_type so the "Group similar" toggle does
   // not collapse two spools that share the base colour but differ on
   // not collapse two spools that share the base colour but differ on
   // gradient stops or visual effect (#1154).
   // gradient stops or visual effect (#1154).
-  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
+  // last_dried_at too: a dried spool is not interchangeable with an undried
+  // one, and folding it into a group would hide the date (#2863).
+  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}|${s.last_dried_at || ''}`;
 }
 }
 
 
 // Column definitions for the inventory table
 // Column definitions for the inventory table
@@ -104,6 +106,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'storage_location', label: 'Storage Location', visible: false },
   { id: 'storage_location', label: 'Storage Location', visible: false },
   { id: 'temperature', label: 'Temperature', visible: false },
   { id: 'temperature', label: 'Temperature', visible: false },
   { id: 'humidity', label: 'Humidity', visible: false },
   { id: 'humidity', label: 'Humidity', visible: false },
+  { id: 'last_dried', label: 'Last Dried', visible: true },
   { id: 'battery', label: 'Battery', visible: false },
   { id: 'battery', label: 'Battery', visible: false },
   { id: 'label_weight', label: 'Label', visible: true },
   { id: 'label_weight', label: 'Label', visible: true },
   { id: 'net', label: 'Net', visible: true },
   { id: 'net', label: 'Net', visible: true },
@@ -184,6 +187,15 @@ const MATERIAL_COLORS: Record<string, string> = {
 
 
 type TFn = (key: string, opts?: Record<string, unknown>) => string;
 type TFn = (key: string, opts?: Record<string, unknown>) => string;
 
 
+// "55°C · 8h" for the last drying (#2863); empty when neither is known, which
+// is the case for a date set by hand or a run Bambuddy did not start or watch.
+function formatLastDriedDetails(spool: InventorySpool): string {
+  const parts: string[] = [];
+  if (spool.last_dried_temp != null) parts.push(`${spool.last_dried_temp}°C`);
+  if (spool.last_dried_hours != null) parts.push(`${Number(spool.last_dried_hours.toFixed(1))}h`);
+  return parts.join(' · ');
+}
+
 function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
 function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
   if (!dateStr) return '-';
   if (!dateStr) return '-';
   const date = parseUTCDate(dateStr);
   const date = parseUTCDate(dateStr);
@@ -235,6 +247,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   storage_location: (t) => t('inventory.storageLocation'),
   storage_location: (t) => t('inventory.storageLocation'),
   temperature: (t) => t('inventory.temperature'),
   temperature: (t) => t('inventory.temperature'),
   humidity: (t) => t('inventory.humidity'),
   humidity: (t) => t('inventory.humidity'),
+  last_dried: (t) => t('inventory.lastDried'),
   battery: (t) => t('inventory.battery'),
   battery: (t) => t('inventory.battery'),
   label_weight: (t) => t('inventory.labelWeight'),
   label_weight: (t) => t('inventory.labelWeight'),
   net: (t) => t('inventory.net'),
   net: (t) => t('inventory.net'),
@@ -271,6 +284,16 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   last_used_time: ({ spool, dateFormat }) => (
   last_used_time: ({ spool, dateFormat }) => (
     <span className="text-sm text-bambu-gray">{spool.last_used ? formatInventoryDate(spool.last_used, dateFormat) : 'Never'}</span>
     <span className="text-sm text-bambu-gray">{spool.last_used ? formatInventoryDate(spool.last_used, dateFormat) : 'Never'}</span>
   ),
   ),
+  last_dried: ({ spool, dateFormat }) => {
+    if (!spool.last_dried_at) return <span className="text-sm text-bambu-gray">-</span>;
+    const details = formatLastDriedDetails(spool);
+    return (
+      <div className="text-sm text-bambu-gray">
+        <div>{formatInventoryDate(spool.last_dried_at, dateFormat)}</div>
+        {details && <div className="text-xs text-bambu-gray/60">{details}</div>}
+      </div>
+    );
+  },
   rgba: ({ spool }) => (
   rgba: ({ spool }) => (
     <div className="flex items-center justify-center">
     <div className="flex items-center justify-center">
       <FilamentSwatch
       <FilamentSwatch
@@ -536,6 +559,7 @@ const columnSortValues: Record<
   added_time: (s) => s.created_at || '',
   added_time: (s) => s.created_at || '',
   encode_time: (s) => s.encode_time || '',
   encode_time: (s) => s.encode_time || '',
   last_used_time: (s) => s.last_used || '',
   last_used_time: (s) => s.last_used || '',
+  last_dried: (s) => s.last_dried_at || '',
   material: (s) => (s.material || '').toLowerCase(),
   material: (s) => (s.material || '').toLowerCase(),
   subtype: (s) => (s.subtype || '').toLowerCase(),
   subtype: (s) => (s.subtype || '').toLowerCase(),
   color_name: (s) => (s.color_name || '').toLowerCase(),
   color_name: (s) => (s.color_name || '').toLowerCase(),
@@ -2266,6 +2290,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                                 locationSensorAboveColor={locationSensorAboveColor}
                                 locationSensorAboveColor={locationSensorAboveColor}
                                 locationSensorBelowColor={locationSensorBelowColor}
                                 locationSensorBelowColor={locationSensorBelowColor}
                                 locationSensorOptimalColor={locationSensorOptimalColor}
                                 locationSensorOptimalColor={locationSensorOptimalColor}
+                                dateFormat={dateFormat}
                               />
                               />
                             );
                             );
                           })}
                           })}
@@ -2291,6 +2316,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                     locationSensorAboveColor={locationSensorAboveColor}
                     locationSensorAboveColor={locationSensorAboveColor}
                     locationSensorBelowColor={locationSensorBelowColor}
                     locationSensorBelowColor={locationSensorBelowColor}
                     locationSensorOptimalColor={locationSensorOptimalColor}
                     locationSensorOptimalColor={locationSensorOptimalColor}
+                    dateFormat={dateFormat}
                   />
                   />
                 );
                 );
               })}
               })}
@@ -2763,6 +2789,7 @@ function PaginationBar({
 function SpoolCard({
 function SpoolCard({
   spool, remaining, pct, onClick, onPrintLabel, onCopy, t,
   spool, remaining, pct, onClick, onPrintLabel, onCopy, t,
   colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
   colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
+  dateFormat,
 }: {
 }: {
   spool: InventorySpool;
   spool: InventorySpool;
   remaining: number;
   remaining: number;
@@ -2775,6 +2802,7 @@ function SpoolCard({
   locationSensorAboveColor: LocationSensorAlertColor;
   locationSensorAboveColor: LocationSensorAlertColor;
   locationSensorBelowColor: LocationSensorAlertColor;
   locationSensorBelowColor: LocationSensorAlertColor;
   locationSensorOptimalColor: LocationSensorAlertColor;
   locationSensorOptimalColor: LocationSensorAlertColor;
+  dateFormat: DateFormat;
 }) {
 }) {
   const bannerStyle = buildFilamentBackground({
   const bannerStyle = buildFilamentBackground({
     rgba: spool.rgba,
     rgba: spool.rgba,
@@ -2845,6 +2873,14 @@ function SpoolCard({
           </div>
           </div>
         </div>
         </div>
         <div className="grid grid-cols-2 gap-2 text-xs">
         <div className="grid grid-cols-2 gap-2 text-xs">
+          {spool.last_dried_at && (
+            <div className="col-span-2">
+              <span className="text-bambu-gray/60">{t('inventory.lastDried')}: </span>
+              <span className="text-bambu-gray">
+                {[formatInventoryDate(spool.last_dried_at, dateFormat), formatLastDriedDetails(spool)].filter(Boolean).join(' · ')}
+              </span>
+            </div>
+          )}
           <div>
           <div>
             <span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
             <span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
             <span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>
             <span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>

Plik diff jest za duży
+ 0 - 0
static/assets/AdditionalSection-3BKr-yip.js


Plik diff jest za duży
+ 0 - 0
static/assets/AdditionalSection-B6WMblUU.js


Plik diff jest za duży
+ 0 - 0
static/assets/AmsUnitCard-D5a7euCZ.js


Plik diff jest za duży
+ 0 - 0
static/assets/ArchivesPage-Bey6mxdW.js


Plik diff jest za duży
+ 0 - 0
static/assets/AssignToAmsModal-DFZcEmxU.js


Plik diff jest za duży
+ 0 - 0
static/assets/CamWallPage-B5jO04uY.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraDiagnoseModal-CwoB6c0T.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraPage-CtlRTieh.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraWall-CnBRKVc6.js


Plik diff jest za duży
+ 0 - 0
static/assets/ColumnConfigModal-DUYMGUzS.js


Plik diff jest za duży
+ 0 - 0
static/assets/ConfigureAmsSlotModal-DKVbw1V5.js


Plik diff jest za duży
+ 0 - 0
static/assets/ConnectAuthorizePage-DX9Qg47q.js


Plik diff jest za duży
+ 0 - 0
static/assets/ContextMenu-4JKs6J8U.js


Plik diff jest za duży
+ 0 - 0
static/assets/ExternalLinkPage-Dv5Pni6e.js


Plik diff jest za duży
+ 0 - 1
static/assets/FileManagerPage-DBtfYDIY.js


Plik diff jest za duży
+ 0 - 0
static/assets/FileUploadModal-5XyUPp1M.js


Plik diff jest za duży
+ 0 - 0
static/assets/FinancePage-MQM248BB.js


Plik diff jest za duży
+ 0 - 0
static/assets/GCodeViewerPage-u7tMfK0M.js


Plik diff jest za duży
+ 0 - 0
static/assets/GroupEditPage-B1zudt8L.js


Plik diff jest za duży
+ 0 - 0
static/assets/ImagePreviewModal-DDwlB_X7.js


Plik diff jest za duży
+ 0 - 0
static/assets/InventoryPage-Cjv3mgeu.js


Plik diff jest za duży
+ 0 - 0
static/assets/InventoryPage-DbBALa1D.js


Plik diff jest za duży
+ 0 - 0
static/assets/LibraryTrashPage-CgpeDHOU.js


Plik diff jest za duży
+ 0 - 0
static/assets/LoginPage-Bn2FDxDx.js


Plik diff jest za duży
+ 0 - 0
static/assets/MaintenancePage-w71H5HP4.js


Plik diff jest za duży
+ 0 - 0
static/assets/ModelSourcesPage-CV-ikPBH.js


Plik diff jest za duży
+ 0 - 0
static/assets/NotificationsPage-CBdOBRKH.js


Plik diff jest za duży
+ 0 - 1
static/assets/PdfPreviewModal-D0aAUfK3.js


Plik diff jest za duży
+ 0 - 0
static/assets/PhotoGalleryModal-BdKyjXYz.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrinterLocationsPage-Bb7lzN87.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrintersPage-DPT4cM9V.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProfilesPage-DU86ah1J.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProjectDetailPage-C-R6U56A.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProjectsPage-BJGrfpoB.js


Plik diff jest za duży
+ 0 - 0
static/assets/QueuePage-CrMbJ9EE.js


Plik diff jest za duży
+ 0 - 0
static/assets/RichTextEditor-DN1-jaEd.js


Plik diff jest za duży
+ 0 - 0
static/assets/SettingsPage-BEMiqw_a.js


Plik diff jest za duży
+ 0 - 0
static/assets/SetupPage-CIt6zcHj.js


Plik diff jest za duży
+ 0 - 0
static/assets/SliceModal-gNieU_I9.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyAmsPage-PNrXLBej.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-D0Gs5NC7.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyDashboard-Bvxja7l8.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CwF7CwGP.js


Plik diff jest za duży
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BQgb_uO0.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-K8IvkOIE.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BBW2wsFD.js


Plik diff jest za duży
+ 0 - 0
static/assets/StatsPage-dlsv0tR5.js


Plik diff jest za duży
+ 0 - 0
static/assets/StreamOverlayPage-B4YEyfrG.js


Plik diff jest za duży
+ 0 - 0
static/assets/SystemInfoPage-BNb6QQrK.js


Plik diff jest za duży
+ 0 - 0
static/assets/de-RHfkhf78.js


Plik diff jest za duży
+ 0 - 0
static/assets/es-5tXCFuNX.js


Plik diff jest za duży
+ 0 - 0
static/assets/fr-DF4R3Wbb.js


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


+ 1 - 1
static/assets/inventorySearch-zXUnjogp.js → static/assets/inventorySearch-CnZFjESL.js

@@ -1 +1 @@
-import{ft as e}from"./index-DCvCEjg1.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-DQNUzRiJ.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Plik diff jest za duży
+ 0 - 0
static/assets/it-Dd9dKQH1.js


Plik diff jest za duży
+ 0 - 0
static/assets/ja-DkzMOqxg.js


Plik diff jest za duży
+ 0 - 0
static/assets/ko-C2MmhZAd.js


Plik diff jest za duży
+ 0 - 0
static/assets/locationSensorDefaults-BSW6Nk3d.js


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