Explorar el Código

Merge pull request #3350 from maziggy/dev2

Dev2
MartinNYHC hace 1 día
padre
commit
4316ede3af
Se han modificado 100 ficheros con 12600 adiciones y 3352 borrados
  1. 4770 0
      .basedpyright/baseline.json
  2. 1 0
      .dockerignore
  3. 2 0
      .github/MAINTAINERS.md
  4. 97 3
      .github/workflows/ci.yml
  5. 3 1
      .github/workflows/security.yml
  6. 6 0
      .github/workflows/windows-installer.yml
  7. 3 0
      .gitignore
  8. 3 1
      .pre-commit-config.yaml
  9. 312 0
      CHANGELOG.md
  10. 71 7
      CONTRIBUTING.md
  11. 3 2
      Dockerfile
  12. 11 6
      Dockerfile.test
  13. 2 2
      README.md
  14. 7 0
      SECURITY.md
  15. 1 1
      UPDATING.md
  16. 9 3
      backend/app/api/routes/_oidc_helpers.py
  17. 14 0
      backend/app/api/routes/_spoolman_helpers.py
  18. 85 15
      backend/app/api/routes/_url_safety.py
  19. 0 33
      backend/app/api/routes/ams_history.py
  20. 40 5
      backend/app/api/routes/api_keys.py
  21. 263 234
      backend/app/api/routes/archives.py
  22. 119 43
      backend/app/api/routes/auth.py
  23. 1 1
      backend/app/api/routes/bug_report.py
  24. 521 176
      backend/app/api/routes/camera.py
  25. 143 52
      backend/app/api/routes/cloud.py
  26. 26 18
      backend/app/api/routes/connected_apps.py
  27. 1 1
      backend/app/api/routes/finance.py
  28. 3 8
      backend/app/api/routes/firmware.py
  29. 17 7
      backend/app/api/routes/github_backup.py
  30. 8 8
      backend/app/api/routes/groups.py
  31. 25 11
      backend/app/api/routes/ha_sensors.py
  32. 170 70
      backend/app/api/routes/inventory.py
  33. 46 4
      backend/app/api/routes/kprofiles.py
  34. 4 5
      backend/app/api/routes/labels.py
  35. 189 103
      backend/app/api/routes/library.py
  36. 3 3
      backend/app/api/routes/library_tags.py
  37. 6 6
      backend/app/api/routes/library_trash.py
  38. 9 9
      backend/app/api/routes/library_variants.py
  39. 3 1
      backend/app/api/routes/local_backup.py
  40. 59 16
      backend/app/api/routes/maintenance.py
  41. 3 0
      backend/app/api/routes/makerworld.py
  42. 3 1
      backend/app/api/routes/manyfold.py
  43. 61 26
      backend/app/api/routes/mfa.py
  44. 8 0
      backend/app/api/routes/notification_templates.py
  45. 205 57
      backend/app/api/routes/notifications.py
  46. 13 6
      backend/app/api/routes/obico.py
  47. 1 1
      backend/app/api/routes/orca_cloud.py
  48. 13 7
      backend/app/api/routes/pending_uploads.py
  49. 67 19
      backend/app/api/routes/pipeline_runs.py
  50. 7 4
      backend/app/api/routes/print_log.py
  51. 353 122
      backend/app/api/routes/print_queue.py
  52. 0 33
      backend/app/api/routes/printer_sensor_history.py
  53. 241 281
      backend/app/api/routes/printers.py
  54. 300 386
      backend/app/api/routes/projects.py
  55. 265 195
      backend/app/api/routes/settings.py
  56. 6 6
      backend/app/api/routes/slice_jobs.py
  57. 2 0
      backend/app/api/routes/slicer_pipelines.py
  58. 8 3
      backend/app/api/routes/slicer_presets.py
  59. 193 168
      backend/app/api/routes/smart_plugs.py
  60. 76 59
      backend/app/api/routes/spoolbuddy.py
  61. 115 70
      backend/app/api/routes/spoolman.py
  62. 114 13
      backend/app/api/routes/spoolman_inventory.py
  63. 2 19
      backend/app/api/routes/support.py
  64. 18 3
      backend/app/api/routes/system.py
  65. 108 29
      backend/app/api/routes/updates.py
  66. 203 12
      backend/app/api/routes/users.py
  67. 90 50
      backend/app/api/routes/virtual_printers.py
  68. 40 16
      backend/app/api/routes/webhook.py
  69. 116 16
      backend/app/api/routes/websocket.py
  70. 13 4
      backend/app/cli.py
  71. 429 226
      backend/app/core/auth.py
  72. 6 8
      backend/app/core/compat.py
  73. 1031 43
      backend/app/core/database.py
  74. 1 2
      backend/app/core/local_config.py
  75. 169 6
      backend/app/core/logging_filters.py
  76. 94 15
      backend/app/core/printer_scope.py
  77. 84 0
      backend/app/core/response_redaction.py
  78. 314 0
      backend/app/core/sqlite_fk.py
  79. 96 8
      backend/app/core/websocket.py
  80. 0 125
      backend/app/i18n/__init__.py
  81. 414 208
      backend/app/main.py
  82. 13 9
      backend/app/models/api_key.py
  83. 4 2
      backend/app/models/archive.py
  84. 12 90
      backend/app/models/auth_ephemeral.py
  85. 4 2
      backend/app/models/finance.py
  86. 3 3
      backend/app/models/github_backup.py
  87. 1 1
      backend/app/models/library.py
  88. 2 0
      backend/app/models/long_lived_token.py
  89. 3 1
      backend/app/models/maintenance.py
  90. 145 89
      backend/app/models/notification.py
  91. 20 19
      backend/app/models/notification_live_activity.py
  92. 13 12
      backend/app/models/notification_lock_screen_widget.py
  93. 2 2
      backend/app/models/notification_template.py
  94. 3 2
      backend/app/models/oidc_provider.py
  95. 9 9
      backend/app/models/print_queue.py
  96. 11 4
      backend/app/models/printer.py
  97. 4 0
      backend/app/models/project.py
  98. 4 2
      backend/app/models/smart_plug.py
  99. 3 1
      backend/app/models/spool.py
  100. 6 2
      backend/app/models/spool_usage_history.py

+ 4770 - 0
.basedpyright/baseline.json

@@ -0,0 +1,4770 @@
+{
+    "files": {
+        "./backend/app/api/routes/_spoolman_helpers.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 87,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/archives.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 67,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/auth.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/bug_report.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/camera.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 14,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 15,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 14,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportUnusedExcept",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/cloud.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/connected_apps.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/finance.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/github_backup.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/inventory.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 9,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 21,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 25,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/library.py": [
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 51,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 58,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalCall",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 14,
+                    "endColumn": 22,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/library_tags.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/library_trash.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/makerworld.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 98,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 82,
+                    "endColumn": 87,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 98,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/manyfold.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 82,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 84,
+                    "endColumn": 88,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/mfa.py": [
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 17,
+                    "endColumn": 22,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/notifications.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 82,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/orca_cloud.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 52,
+                    "endColumn": 68,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/pending_uploads.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/pipeline_runs.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 93,
+                    "endColumn": 100,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 95,
+                    "endColumn": 102,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/print_log.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/print_queue.py": [
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 76,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/printer_locations.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 17,
+                    "endColumn": 25,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 87,
+                    "endColumn": 95,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 52,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/printers.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 17,
+                    "endColumn": 104,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 78,
+                    "endColumn": 85,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/projects.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 20,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/settings.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 64,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 20,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 17,
+                    "endColumn": 21,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/slicer_pipelines.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/slicer_presets.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/spoolbuddy.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 13,
+                    "endColumn": 106,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/spoolman.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 81,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 85,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/spoolman_inventory.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 4,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 51,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 13,
+                    "lineCount": 6
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 92,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 13,
+                    "lineCount": 6
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 9,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 13,
+                    "lineCount": 6
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 52,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/support.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 14,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/system.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/webhook.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/core/auth.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/core/database.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 10,
+                    "endColumn": 18,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportInvalidTypeForm",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 25,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 67,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 96,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 80,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/core/encryption.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 83,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/main.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 100,
+                    "endColumn": 110,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 65,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 74,
+                    "endColumn": 76,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 45,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 45,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 72,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 82,
+                    "endColumn": 99,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 73,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 71,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 107,
+                    "endColumn": 109,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 79,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 71,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 81,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 53,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 61,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 53,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/models/library.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/schemas/external_link.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 4,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/announcements.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 14,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/archive.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 102,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/archive_cost_estimate.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/bambu_ftp.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 45,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/bambu_mqtt.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalCall",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 13,
+                    "lineCount": 15
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/camera.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/discovery.py": [
+            {
+                "code": "reportOperatorIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/email_service.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/energy_price.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/export.py": [
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 16,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 16,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/external_camera.py": [
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 76,
+                    "endColumn": 112,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 93,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 83,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 77,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 18,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 59,
+                    "endColumn": 86,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 78,
+                    "endColumn": 105,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 83,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 55,
+                    "endColumn": 82,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 77,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/failure_analysis.py": [
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/finance_budget.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/git_providers/github.py": [
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/github_backup.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/ha_sensor_manager.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/label_renderer.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/library_folder_access.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/library_trash.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/location_ha_sensor_manager.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/mesh_combine.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 65,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 92,
+                    "endColumn": 97,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 28,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 71,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/model_providers/makerworld/service.py": [
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 51,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 78,
+                    "endColumn": 86,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 77,
+                    "endColumn": 85,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/model_providers/manyfold/service.py": [
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalIterable",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/mqtt_relay.py": [
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/mqtt_smart_plug.py": [
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/notification_service.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 73,
+                    "endColumn": 80,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 91,
+                    "endColumn": 98,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 87,
+                    "endColumn": 94,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/notify_live_activities.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/notify_widgets.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/notify_worker.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 59,
+                    "endColumn": 61,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 68,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/obico_detection.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/opentag3d.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/pipeline_eligibility.py": [
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/plate_detection.py": [
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 21,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 53,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 18,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 21,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 74,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 58,
+                    "endColumn": 61,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 22,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 72,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 22,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 71,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 64,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/plate_thumbnail.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/print_scheduler.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 55,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 100,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 86,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 80,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 80,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 83,
+                    "endColumn": 116,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 83,
+                    "endColumn": 116,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 81,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 51,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 64,
+                    "endColumn": 79,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 55,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 59,
+                    "endColumn": 74,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 71,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 84,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/printer_media.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/rest_smart_plug.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/slice_preview.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/smart_plug_manager.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOperatorIssue",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 55,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 84,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/spoolman.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 78,
+                    "endColumn": 88,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/spoolman_tracking.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/stl_thumbnail.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 77,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 28,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 70,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 53,
+                    "endColumn": 61,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/tasmota.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/certificate.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/ftp_server.py": [
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/manager.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 74,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 74,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalCall",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalCall",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/mqtt_bridge.py": [
+            {
+                "code": "reportOptionalIterable",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalIterable",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/mqtt_server.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/tcp_proxy.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOperatorIssue",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportRedeclaration",
+                "range": {
+                    "startColumn": 13,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/utils/archive_paths.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/utils/threemf_tools.py": [
+            {
+                "code": "reportRedeclaration",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 20,
+                    "lineCount": 1
+                }
+            }
+        ]
+    }
+}

+ 1 - 0
.dockerignore

@@ -17,6 +17,7 @@ __pycache__
 .Python
 venv/
 .venv/
+.typecheck-venv/
 ENV/
 env/
 .env

+ 2 - 0
.github/MAINTAINERS.md

@@ -38,6 +38,7 @@ Enable these rules:
 - [x] Require branches to be up to date before merging
 - Add these status checks (they appear after CI runs once):
   - `Backend Lint`
+  - `Backend Type Check`
   - `Backend Tests`
   - `Frontend Lint`
   - `Frontend Type Check`
@@ -64,6 +65,7 @@ The CI workflow (`.github/workflows/ci.yml`) runs on:
 | Job | Purpose | Required for PR |
 |-----|---------|-----------------|
 | `backend-lint` | Ruff linting + format check | Yes |
+| `backend-typecheck` | basedpyright against the baseline (pinned env, `requirements-typecheck.lock`) | Yes |
 | `backend-tests` | Unit tests | Yes |
 | `frontend-lint` | ESLint | Yes |
 | `frontend-typecheck` | TypeScript compilation | Yes |

+ 97 - 3
.github/workflows/ci.yml

@@ -53,6 +53,43 @@ jobs:
       - name: Run ruff format check
         run: ruff format --check backend/
 
+  backend-typecheck:
+    name: Backend Type Check
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Install the pinned type-check environment
+        # requirements-typecheck.lock, not requirements.txt: the baseline only
+        # holds for these exact library versions (see requirements-typecheck.txt).
+        run: pip install -r requirements-typecheck.lock
+
+      - name: Check the lock covers requirements.txt
+        # A dependency added to requirements.txt but not to the lock would show
+        # up below as an unresolved import instead of saying what is wrong.
+        run: |
+          if ! out=$(pip install --dry-run -r requirements.txt 2>&1); then
+            echo "$out"
+            echo "::error::pip could not resolve requirements.txt against the type-check environment."
+            exit 1
+          fi
+          if echo "$out" | grep '^Would install'; then
+            echo "::error::requirements-typecheck.lock is missing packages from requirements.txt. Regenerate it as described in requirements-typecheck.txt."
+            exit 1
+          fi
+
+      - name: Run basedpyright
+        # Errors recorded in .basedpyright/baseline.json pass; any new one fails.
+        # Lock mode also fails when a baselined error was fixed but the smaller
+        # baseline was not committed.
+        run: basedpyright --pythonpath "$(which python)" --baselinemode=lock
+
   backend-security:
     name: Backend Security
     runs-on: ubuntu-latest
@@ -72,7 +109,7 @@ jobs:
           # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
           # A fix exists, so we upgrade rather than --ignore-vuln.
           python -m pip install --upgrade pip setuptools
-          pip install -r requirements.txt
+          pip install -r requirements.lock
           pip install pip-audit
 
       - name: Run pip-audit
@@ -113,14 +150,15 @@ jobs:
         uses: actions/cache@v5
         with:
           path: ~/.cache/pip
-          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
+          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.lock', 'requirements-dev.txt') }}
           restore-keys: |
             ${{ runner.os }}-pip-
 
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install -r requirements.txt
+          # The pinned versions that ship (Docker, installers), not whatever is newest.
+          pip install -r requirements.lock
           pip install -r requirements-dev.txt
 
       - name: Run tests (shard ${{ matrix.shard }}/4)
@@ -154,6 +192,58 @@ jobs:
             --splits 4 --group ${{ matrix.shard }} \
             --durations-path tests/.test_durations
 
+  backend-postgres-upgrade:
+    # The test suite runs on SQLite only. This builds PostgreSQL databases with
+    # older releases' code, upgrades them and compares each with a fresh
+    # install (see scripts/check_postgres_upgrade.py).
+    name: PostgreSQL Upgrade Check
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
+    needs: backend-lint
+    services:
+      postgres:
+        # The version the docker-compose example and the wiki use.
+        image: postgres:16-alpine
+        env:
+          POSTGRES_PASSWORD: ci
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 5s
+          --health-timeout 5s
+          --health-retries 20
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          # The release tags: older releases' code builds the databases to upgrade.
+          fetch-depth: 0
+
+      - name: Set up Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Cache pip
+        uses: actions/cache@v5
+        with:
+          path: ~/.cache/pip
+          # Its own key: this job installs the lock only, and finishes long
+          # before the test shards, so sharing theirs would save a cache
+          # without the dev packages under their key.
+          key: ${{ runner.os }}-pip-lock-${{ hashFiles('requirements.lock') }}
+          restore-keys: |
+            ${{ runner.os }}-pip-lock-
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.lock
+
+      - name: Upgrade from older releases and compare with a fresh install
+        timeout-minutes: 10
+        run: python scripts/check_postgres_upgrade.py --url postgresql://postgres:ci@localhost:5432/postgres
+
   # ============================================================================
   # Frontend Checks
   # ============================================================================
@@ -180,6 +270,10 @@ jobs:
         working-directory: frontend
         run: npm run lint
 
+      - name: Unused files and dependencies (knip)
+        working-directory: frontend
+        run: npm run check:knip
+
   frontend-security:
     name: Frontend Security
     runs-on: ubuntu-latest

+ 3 - 1
.github/workflows/security.yml

@@ -12,6 +12,7 @@ on:
       - 'Dockerfile'
       - 'docker-compose*.yml'
       - 'requirements.txt'
+      - 'requirements.lock'
       - 'frontend/package*.json'
       - '.github/workflows/security.yml'
   pull_request:
@@ -22,6 +23,7 @@ on:
       - 'Dockerfile'
       - 'docker-compose*.yml'
       - 'requirements.txt'
+      - 'requirements.lock'
       - 'frontend/package*.json'
       - '.github/workflows/security.yml'
   workflow_dispatch:
@@ -141,7 +143,7 @@ jobs:
           # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
           # A fix exists, so we upgrade rather than --ignore-vuln.
           python -m pip install --upgrade pip setuptools
-          pip install -r requirements.txt
+          pip install -r requirements.lock
           pip install pip-audit
 
       - name: Run pip-audit

+ 6 - 0
.github/workflows/windows-installer.yml

@@ -118,6 +118,9 @@ jobs:
           name: bambuddy-windows-installer-unsigned
           path: installers/windows/build/output/*.exe
           if-no-files-found: error
+          # Only needed while SignPath fetches it during this run; the default
+          # 90 days of every daily build filled the account's Actions storage.
+          retention-days: 1
 
   publish:
     needs: build
@@ -235,6 +238,9 @@ jobs:
           name: bambuddy-windows-installer
           path: installers/windows/build/output/*.exe
           if-no-files-found: error
+          # Release tags attach the installer to the release below; the run
+          # copy is only for downloading a manual or daily build.
+          retention-days: 7
 
       - name: Attach installer to release
         if: startsWith(github.ref, 'refs/tags/v')

+ 3 - 0
.gitignore

@@ -1,6 +1,8 @@
 # Claude
 .claude/
 CLAUDE.md
+CLAUDE.local.md
+.mcp.json
 
 # macOS
 .DS_Store
@@ -14,6 +16,7 @@ __pycache__/
 *.so
 venv/
 .venv/
+.typecheck-venv/
 env/
 .env
 docker-compose.override.yml

+ 3 - 1
.pre-commit-config.yaml

@@ -23,7 +23,9 @@ repos:
         # doesn't drift the files away from upstream.
         exclude: ^static/
       - id: end-of-file-fixer
-        exclude: ^static/
+        # .basedpyright/baseline.json is written by basedpyright without a final
+        # newline, and rewritten by it whenever an error is fixed.
+        exclude: ^(static/|\.basedpyright/)
       - id: check-yaml
       - id: check-json
         exclude: ^(static/|frontend/tsconfig\.)

+ 312 - 0
CHANGELOG.md

@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **USB cameras are picked from a list** — With an external camera set to **USB Camera (V4L2)**, Settings > Camera now lists the video devices the Bambuddy server can see, by name and path (`HD Pro Webcam C920 (/dev/video0)`), instead of asking for the device path. A refresh button looks again after a camera is plugged in, and a saved camera that is unplugged stays selected, marked **not found**. When no device is visible, the list says how to pass a camera to the Docker container. **Enter path manually** still takes a typed path. Looking for devices no longer holds up the server while it runs.
+- **The MQTT relay publishes printer offline/online and low filament** — Three new topics for Home Assistant, Node-RED and other subscribers. `printers/{serial}/offline` is sent once a printer has been disconnected for a minute, and `printers/{serial}/online` when it comes back after that, so a short blip sends neither. `printers/{serial}/filament/low` is sent once when the spool in an AMS slot drops below its low-stock threshold, at the same moment as the Low Filament notification, and works even when no notification provider has that event switched on. Wiki updated with the payloads.
 - **Notify! notification provider with iOS Live Activities and Lock Screen widgets (#2919, requested and contributed by @simplytoast1 in #3265)** — Notify! used to need the separate notify-bambuddy companion container. Bambuddy now talks to it directly: add a provider of type Notify! with the device ID and token.
   - **Alerts:** the usual events, printer filter, quiet hours and daily digest apply as for every provider, with the camera photo attached. Each printer gets its own notification thread. **Time Sensitive problem alerts** sends failures, stops, printer errors and failure detection as time-sensitive notifications.
   - **Live Activities:** with **Live Activities (iOS)** on, each running print shows a tile on the Lock Screen with a countdown to the end. A fault, a pause, running out of filament or a lost connection replaces the countdown with the problem. Progress style, extra values (progress, ETA, layer, temperatures), print stage, a privacy mode that hides the file name, symbol, colour and a button that opens Bambuddy can be set under **Live Activity appearance**. A dismissed tile stays dismissed for that print, and tiles survive a Bambuddy restart.
@@ -134,6 +136,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 ### Changed
+- **Unused API routes and settings removed** — For anyone calling the API from scripts:
+  - **Removed routes:** `POST /printers/test` (it took the access code in the URL; adding a printer tests the connection itself), the two debug routes `POST /printers/{id}/debug/simulate-print-complete` and `GET /printers/{id}/runtime-debug`, and `POST /spoolbuddy/devices/{id}/update-status`, which SpoolBuddy stopped calling when updates moved to SSH.
+  - **Old virtual printer settings:** `GET /settings/virtual-printer` now returns only `tailscale_disabled`, `archive_name_source` and `status`, and `GET /settings` no longer lists `virtual_printer_enabled`, `virtual_printer_access_code` and `virtual_printer_mode` (`PUT /settings` ignores them). These settings stopped doing anything when each virtual printer got its own settings; use `/virtual-printers` instead.
+- **A grouping that ended in failures is marked failed** — A grouping whose jobs all failed, or failed and were cancelled, with nothing printed, stayed **active** for good, even through the start-up clean-up, and piled up next to real work. It is now marked **failed**, shown in red with a **Failed** filter on the Batches tab, and opens again if a job is added to it. Orders are unchanged: a failed run still leaves an order owing it.
+- **Deleting a custom maintenance type says it applies to every printer** — Maintenance types are shared, so deleting a custom one removes it, with its maintenance history, from every printer, including those of other teams when printer access is limited. The confirmation now says so.
 - **The web interface downloads about 70% less on each new version (#3175, requested by @mason9468)** — The interface's script files were sent uncompressed and with no caching instructions, so a phone fetched the whole 11 MB again and the browser asked for it on every load.
   - **Compressed:** scripts, styles and other text files under `/assets` now go out gzip-compressed when the browser accepts it. The main script drops from 11.0 MB to 3.0 MB.
   - **Cached for good:** files whose name carries a content hash are now marked as never changing, so the browser loads them from its own cache until an update brings new ones. The page itself is still checked on every load, so updates are picked up as before.
@@ -161,6 +168,311 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Saving in the timelapse editor could fail on long videos, and ignored the music volume** — Save re-encoded the video while the browser waited, which for a long timelapse took minutes. A browser or reverse proxy that gave up showed an error while FFmpeg went on running on the server.
+  - **Runs in the background:** Save now starts the work on the server and returns at once. The button shows how far it has got, **Cancel** stops FFmpeg and keeps the original, and an editor closed and opened again shows the progress. While one edit of a timelapse runs, Save is unavailable for it. If the timelapse is removed or replaced while the edit runs, the edit is not saved over it.
+  - **Music volume:** the volume slider only changed the preview; the saved video always had the music at full volume. It is now mixed in at the volume set, and muted means silent.
+  - On a broken file, reading the video's details now gives up after a minute and each timeline frame after 30 seconds, instead of waiting forever.
+- **Firmware could be uploaded to a printer in the middle of a print** — The firmware update dialog offered **Upload Firmware** whatever the printer was doing, and the upload wrote to the SD card the print runs from. It is now only offered while the printer is idle (a finished or failed print counts), and the server refuses it otherwise. If a print starts while the firmware is still downloading, nothing is written to the card, and the queue holds its next print for a printer until its firmware upload is over.
+- **Reset Slot cleared an AMS slot on a single click, even mid-print** — It now asks first, and is unavailable while a print is running, paused or starting, since the print may be feeding from that slot.
+- **Some destructive buttons acted without asking** — **Clear all** on the shopping list, **Clear** in the Git backup history and removing a linked SSO account now ask for confirmation first. An account created through SSO, which has no password, can no longer remove its last SSO link, which was its only way to sign in.
+- **LDAP and SMTP "Test" checked the saved settings, not the form** — Changing a field and clicking **Test Connection** or **Test Email** tested the old values, so a test could pass on settings that weren't the ones about to be saved. Both now test what the form holds, before saving. LDAP's test also works while LDAP is still disabled, and in that case now uses a saved CA certificate path too. An empty password keeps the saved one, as when saving; with a changed server it has to be entered again.
+- **A user's email couldn't be removed** — Emptying the email field when editing a user saved without error but kept the old address. It is now removed. The edit is refused while advanced authentication is on, or while the user signs in with email codes (email two-factor authentication), since both need the address.
+- **Deleting a local backup failed silently** — When the server refused or failed the delete, no message was shown and the file stayed in the list. The error is now shown.
+- **Purging archives could delete by an age that wasn't previewed** — The "Purge old archives" dialog previews what it will delete a moment after you stop typing, but the delete button took the age currently in the field. Changing the age and clicking straight away deleted archives for the new age while the button still showed the old preview's count. The button now waits until the preview matches the field, and the purge uses the previewed age.
+- **Editing a transaction to "No cost center" kept the old cost center** — Finance showed "Transaction updated", but the entry stayed booked to its previous cost center.
+- **After deleting a printer, spools still showed as loaded in it** — The spool assignment list wasn't refreshed, so the inventory kept showing spools in the deleted printer's slots for up to 30 seconds.
+- **API keys of Operators were refused on queue, archive and library routes** — A key whose owner may act only on their own rows (the built-in Operators group) got `403 API key owner does not have 'queue:update_all' permission` when it listed, edited, cancelled or deleted even its owner's own queue jobs, and the same on archives and library files. Such a key now acts on its owner's rows and not on anyone else's. Keys of owners with the `_all` permissions, and keys without an owner, reach every row as before.
+- **Statistics: Today, This Week and the other ranges used UTC days** — Near midnight they showed the wrong day's prints for anyone outside UTC: in Berlin at 01:30, "Today" showed the previous day. A day now runs from midnight to midnight in your browser's time zone, for every date filter on the Statistics page, and the date pickers allow today. API callers can name the zone with the new `tz` parameter on the date-filtered archive and statistics endpoints; without it, a date is a day in Bambuddy's own `TZ` (UTC days before).
+- **A print that failed on its first run left the archive costing only the part it printed** — With the built-in inventory, the archive took the failed run's partial cost, so a reprint was estimated, reserved and billed from that fraction. The archive now keeps the cost of one full print (as in Spoolman mode); the failed run's own cost is still recorded in the Print Log.
+- **Spoolman: a very short print could end with its estimated cost instead of the real one** — The cost estimate made at print start could still be waiting on Spoolman when the print ended, and then wrote over the real cost. Completion now stops it first.
+- **After a 2FA or SSO login, LDAP and OIDC users were offered "Change Password"** — The login answered as if they were local users, until the page was reloaded.
+- **The same printer fault could be listed twice** — A fault the printer reported both as a print error and in its HMS list was not recognised as the same one.
+- **H2D: a refused manual filament load could show the wrong active tray later** — Its target slot was remembered until something cleared it, so a print loading the same slot number on another AMS much later was shown as that slot. It is now forgotten after two minutes.
+- **K-profiles: a printer refusing the re-applied profile after an RFID read was not in the logs** — Only the "applied" line was, which made wrong pressure-advance prints hard to diagnose.
+- **K-profiles: opening several pages at once could fetch the cloud filament names many times** — Each one fetched every custom preset from Bambu Cloud while holding a database connection; they now share one fetch.
+- **Library API: changing a file's folder could move it into or out of an external folder without moving the file** — The file then showed in a folder it was not in. That change is now refused with a pointer to the move endpoint, which moves the file itself.
+- **Slicing: a slow progress answer was reported as "Slicer sidecar unreachable"** — It now says the sidecar did not answer in time.
+- **Printer card: the cooling/heating mode switched on the printer showed the old mode** — The live updates did not carry it, so the card caught up only with its next refresh.
+- **Printer card: an H2C rack slot's material label disappeared** — The live updates replaced the rack without each slot's filament type, so the label fell back to the filament ID or went blank until the next refresh.
+- **Git backup: a print running when the backup was made came back as a failed print** — Restored as still printing, the archive was closed on the next printer reconnect as if the print had just failed, with a failure notification and a Print Log entry. It is now restored as cancelled ("No status update received"), the way Bambuddy closes a print it lost track of; an archive this instance still has keeps its own status on overwrite.
+- **Git backup: spools came back as never used** — When each spool was last used was not in the backup. It is now backed up and restored (an older backup leaves the value already there).
+- **File manager: a slow folder listing showed as "printer unavailable"** — Listing a large folder (such as the camera recordings) on a slow SD card can outlast the wait, and was reported like an unreachable printer; every refresh also started another listing while the last one still ran. It now says the listing is taking too long, and a refresh waits for the listing already running.
+- **Printer files from the evening could be dated a year back** — File times on the printer carry no year, and the year was taken from the host's local clock while printers report UTC. On a host behind UTC, files written in the evening were dated a year back, so their camera recordings were not matched to their prints.
+- **SpoolBuddy: writing a sticker could break a Bambu spool's AMS link in Spoolman mode** — A Bambu spool created from the AMS is linked by its RFID tray ID; it was listed as untagged on the Write Tag page, and a sticker written to it replaced that link in Spoolman, so the next AMS sync created a duplicate spool. It is no longer listed as untagged, and on a Spoolman that does not also keep the tray ID as a native tag (before 0.27) the write is refused with the reason.
+- **SpoolBuddy: Write Tag warned "color_name not set" for every Spoolman spool** — The warning looked at a Spoolman field that is never filled; Bambuddy keeps the colour name with the spool. It now warns only when the name is missing, and says the tag then carries the subtype instead.
+- **SpoolBuddy: an update reported success after installing its dependencies timed out** — The device was restarted while the install could still be running. A timeout now stops the update with an error (and the time allowed went from 2 to 5 minutes); a failed install still completes, and its output is now in the log.
+- **Virtual printer: a late slicer update could rewrite a queue job already being sent** — The slicer's print options can reach a virtual printer seconds after the job was queued. If the scheduler had already started sending the job, its AMS mapping and options were still rewritten, so the queue entry -- and the spool charged from it -- could differ from what the printer printed. Such a job is now left as it was sent.
+- **Backup restore: the virtual printers kept running during the restore** — The step meant to stop them did nothing, so their FTP, MQTT and discovery services stayed up while files were replaced. They are now stopped, and start again with the restart a restore asks for.
+- **API: the legacy virtual printer settings route answered 200 for settings it no longer applies** — `PUT /settings/virtual-printer` saved enabled, access code, mode, model, target printer and remote interface to settings nothing reads (each virtual printer keeps its own). It now refuses them with a pointer to `PUT /virtual-printers/{id}`; the archive name source and Tailscale switch still work there.
+- **Settings: removed "Save thumbnails" and "Notification Language", which did nothing** — Nothing read either: thumbnails were always stored, and notifications always used their template text as written. The API still accepts both fields.
+- **Restoring an old SQLite backup onto PostgreSQL could put some timestamps hours off** — Required timestamps the backup left empty were filled with the host's local time, while every other timestamp is stored in UTC, so on a host ahead of UTC those rows appeared hours in the future. They are now filled in UTC.
+- **Maintenance: a printer's time offline was counted as print time** — A printer that dropped off mid-print and came back printing hours later had the whole gap added to its runtime, so hours-based maintenance came due early. Time while Bambuddy itself was restarting is still counted, as before.
+- **Maintenance: duplicate maintenance items** — A printer's items are created when its maintenance is first shown, and two pages (or a page and the background check) could each create the same one; marking it performed could then update the copy not shown, and assigning that type again failed. Existing duplicates are merged on upgrade (the most recently performed one is kept, or the one the page showed when none was, with the history of all), and they can no longer be created.
+- **Maintenance API: "performed" answered in hours for an item set to days** — Its response used the maintenance type's interval type instead of the item's own setting, which the list already used.
+- **Notifications: the daily digest could be lost** — When the digest could not be sent (a network error, say), its events were deleted anyway. A digest time inside the provider's quiet hours was skipped, logged as sent, and its events deleted too. The digest now goes out at its time even during quiet hours, and a failed send keeps the events for the next day's digest (events more than two days old are dropped, so a provider that keeps failing does not pile them up). Lines from an earlier day show their date.
+- **Notifications: a later print could be attributed to the user of an earlier queued one** — When a queued print's archive was deleted while it was being sent to the printer, the record of who queued it was left behind, and an unrelated later print of the same name on that printer named and emailed that user.
+- **Notifications: "Print Stopped" after a printer came back online showed the time it was offline as the print's duration** — A print that ended while the printer was offline is closed when it reconnects; its real end time is unknown, so the notification now shows the sliced estimate instead of, say, 14 hours.
+- **Notification templates: the print outcome confirmation had no variables or preview in the editor** — The editor now lists its variables (including the confirmation link) and previews it, and seven other events (AMS humidity and temperature, bed cooled, the sensor alerts, auto-drying suspended, missing spool assignment) have their names.
+- **Configure Slot: a manual K value that could not be sent was reported as applied** — With no K-profile picked, a typed K value goes to the printer on its own command; when that send failed, the slot still read as configured. A warning now says the filament was set but the K value was not sent.
+- **A stray Stop on an idle printer could relabel a later failed print as cancelled** — Stop on a printer with no print loaded still marked it as stopped by the user. If the next print was first seen mid-way (after Bambuddy reconnected), that print's real failure was then reported as the user's cancel. The mark is now only set while a print is loaded (or the printer's state is unknown).
+- **Skip Objects and the printer connection diagnostic held a database connection through their printer I/O** — For a print started outside Bambuddy, the object list pulls the file over FTP, which on a printer mid-print can take a while; the diagnostic probes the printer and waits up to 10 seconds for a report. The connection is now released first.
+- **Camera light: the plate check could switch the light off under a live view** — With the camera light setting on, a live view opened while the print-start plate check had the chamber light on found it already lit, so it never took it, and the check then switched it off: the view went dark. The plate check now holds the light like every other camera use (and still lights the chamber whatever the camera light setting), so it goes off only once nothing uses the camera, after the usual short grace.
+- **External MJPEG cameras: timelapse frames, finish photos and Obico checks failed while someone watched** — A single-client camera (an ESP32 stream, for example) refused the second connection these opened while a live view was running. They now take the live view's frame, as they already did for the printer's own camera. A camera with a separate snapshot URL keeps using that URL.
+- **X1C live view could stop instead of reconnecting** — The printer ends each camera session about once a minute and Bambuddy redials; a background cleanup that ran at that moment could make the live view end instead.
+- **Camera test, diagnose, plate check and plate calibration held a database connection for the whole capture** — Up to 30 seconds each, which on a large farm could exhaust the connection pool. They now release it before capturing.
+- **API: `GET /queue?status=` with several statuses returned nothing** — The documented `?status=pending,printing` was matched as one status. Comma-separated statuses are now each matched.
+- **Saving an edited queue item showed "Queue item updated" twice** — The dialog also closed twice, a failed save showed two error toasts, and an edit that also added the job for further printers closed the dialog before those were added. It now reports and closes once, after everything is saved.
+- **A failed Unassign on the printer card said nothing** — Unassigning a spool from an AMS slot (built-in inventory or Spoolman) that the server refused showed no message, and the spool simply stayed assigned. The error is now shown.
+- **The OBS stream overlay ignored live updates** — It read the printer's status updates from the wrong field, so it moved only with its 2-second refresh instead of in real time.
+- **Uploading two files with the same name could show the wrong result per file** — When both were uploaded together, both rows showed the first file's result, so one that failed looked uploaded. Each row now shows its own file's result.
+- **The queue timeline and history rows showed plate 1 for every plate** — A queued plate of a multi-plate file appeared with the file's first plate as its picture, while the queue list showed the right plate. All three now show the queued plate.
+- **Inventory: removed the "Full" column** — It showed whether a spool had been added full, which nothing ever recorded, so every row read "-".
+- **Spoolman: the print dialog's cost estimate uses the Spoolman spool's price** — In Spoolman mode, the print dialog estimated each slot from the built-in inventory's assignments, which hold nothing in that mode or rows left from before the switch to Spoolman. The estimate (and the budget check that uses it) came out at the default price per kg or at a spool no longer in the slot, and the "g left" shown for each slot was wrong the same way. Both now come from the spool bound to the slot, priced the way the print is charged: the spool's own price, else its filament's, else the default.
+- **Spoolman: a print whose 3MF arrived late is charged from the 3MF** — A print that started before Bambuddy could fetch its 3MF (an "Untitled" print, a printer briefly unreachable) gets an empty archive that is filled in once the 3MF turns up. In Spoolman mode its filament was still charged from the AMS remaining-percentage change — in steps of 10 g on a 1 kg spool, nothing for a spool without a percentage, and nothing at all when no slot reported one at the start. The recovered 3MF's usage is now used, as the built-in inventory already did.
+- **Spoolman inventory: Cost/kg shows the price prints are charged** — A Spoolman spool priced only through its filament (the spool itself has no price, or a price of 0) showed "-" or "0.00" in the Inventory's Cost/kg column and no price on the SpoolBuddy spool card, although every print off it was charged that filament price. Both now show it, the column marked "(filament price)"; the spool's own price still wins, and editing the spool does not copy the filament's price onto it.
+- **Spoolman AMS syncs keep a spool assigned by hand, and say what they changed** — Three cases:
+  - **A spool assigned to an AMS slot by hand** while the automatic Spoolman sync was waiting for Spoolman could be replaced by the spool the sync had read before. The syncs now change only the slots whose spool they found different, and only while the slot still holds what they read.
+  - **The Sync buttons in Spoolman settings** changed slots without telling other open pages, and without updating the slot's filament preset, so the printer card could keep the previous spool's preset name. They now do both, as the automatic sync does.
+  - **The Sync buttons** gave a newly created Spoolman spool the remaining weight of a built-in inventory spool last assigned to that slot before the switch to Spoolman, when the printer reported no weight of its own. That leftover weight is no longer used, as in the automatic sync.
+- **Spoolman inventory: five requests refused or reported correctly** —
+  - **Deleting a storage location while Spoolman was unreachable** counted its spools from the built-in inventory, found none, and deleted a location Spoolman spools still used. The delete is now refused until Spoolman answers.
+  - **Printing labels for an archived Spoolman spool** failed the whole batch with "not found", as Spoolman leaves archived spools out of its plain spool list.
+  - **An archived Spoolman spool could be assigned to an AMS slot** (or put on one by a tag link) and then charged the next print's usage. It is now refused, as in the built-in inventory.
+  - **A slot assignment or tag link for an AMS number no printer has** failed with a server error, and the link had already written the tag to Spoolman. Such a request is now refused before anything is written.
+  - **Resetting the "Total Consumed" counters** reported success even when some or all of them failed. The result now says how many failed, as the other bulk actions do.
+- **A Git backup restore keeps "Reset Total Consumed"** — The backup did not include the point each spool's "Total Consumed" counter was reset at, so a restore brought every reset counter back to its full lifetime figure. It is now backed up and restored; a backup made before this keeps the reset points the spools already have.
+- **A finished print's filament is charged once** — Two cases in the built-in inventory:
+  - **An AMS update right as a print finished** could raise the spool's used weight from the AMS remain% while the print's exact usage was still being worked out, and that usage was then added on top: the print was charged twice. The AMS sync now waits until the print is charged, as it does while the print runs.
+  - **A print the printer reported finished without any file name** (one that failed while preparing, right after Bambuddy started) kept the AMS weight sync off until the next print started. It now turns the sync back on.
+- **Spool weights and slot assignments written at the same moment no longer undo each other** — Three cases:
+  - **Assigning or unassigning a spool by hand, or adding a slot's spool to the inventory,** while the printer reported an AMS change for the same slot (or on a double-click) could fail with a server error on PostgreSQL. These now wait for the AMS update, as AMS updates already wait for each other.
+  - **A weight set by hand or weighed on the SpoolBuddy scale** just as the AMS remain% sync ran could be overwritten by the AMS figure, with the weight still marked as locked.
+  - **The same weight set at the end of a print** was replaced by the weight from before it plus the print's usage. The usage is now added to the stored weight.
+- **Inventory views stay current, and spools follow their printers and slots** — Five cases:
+  - **Deleting a spool** left it shown in its AMS slot in other open tabs, and **a weight from the SpoolBuddy scale, the automatic AMS sync or "Sync Weights from AMS"** reached other open tabs and the printer cards only when they next refreshed. These changes are now announced like any other inventory change, and the printer cards also show the weight a finished print leaves straight away.
+  - **Deleting a printer** on SQLite (the default database) left its spools assigned to it, shown as loaded on a printer that no longer exists. Their slots are now freed, and assignments left behind by earlier deletes are cleared at the next start.
+  - **A spool assigned to an empty slot from SpoolBuddy** that the printer could not be told about when filament went in (for example while it was reconnecting) was marked done anyway and never configured. It is now tried again on the next AMS change.
+  - **A spool CSV import** stored a `last_used` time with a timezone offset as if it were UTC on SQLite; it is now converted.
+- **Filament matching, preheat temperatures and colour names follow the plate's own filaments** — Three cases:
+  - **An "Any model" job with a filament override** waited forever when the printers held an equivalent filament under another name (PA-CF loaded for a PA12-CF override). The type check already counted it as the same filament, but the colour check that picks the printer did not.
+  - **Preheating a plate of a multi-filament project** heated the bed for the hottest filament of the whole project: a PLA plate in a project that also holds PETG was preheated, and kept warm between jobs, at the PETG temperature. Preheat and keep-warm now heat the bed for the filaments of the plate the job prints.
+  - **Colour names on the queue page** came from the plain filament type, so a colour named differently in a product line (the same black is "Charcoal" in PLA Matte) showed the generic name. The queue page, and the colour name the print dialog saves with a filament override, now name it within the filament's product line, as the print dialog already showed it. Jobs queued before keep the name they were saved with.
+- **Stopping and cancelling leave nothing running or reserved** — Four cases:
+  - **Cancelling a pipeline run** set its queued jobs to cancelled, but a job already preheating went on heating the printer (up to its soak time), and its share of the cost centre's budget stayed reserved. Both now happen as when the job is cancelled from the queue.
+  - **Stopping a job while its printer is offline** left its share of the cost centre's budget reserved until Bambuddy restarted; it is now released at once.
+  - **A printer slow to answer the end-of-print file cleanup** (up to about nine minutes) held up the end of the print so long that the stranded-job check closed the job first, and its auto power-off and its charge were skipped. The cleanup now gives up after 45 seconds and leaves the file on the card.
+  - **A print start the printer refused** is now logged with the printer's reason, and the job goes back to the queue for a retry at once. Bambuddy used to read the refusal as a print it accepted and waited three minutes for it to start.
+- **Queue jobs added through the API reach the printers they should** — Three cases in the API and webhooks:
+  - **A model in lower case** (`"target_model": "x1c"`) was refused with "No active printers for model", although the scheduler matches models without regard to case. Adding (one job, alternatives, or library files), editing and pipelines now match it the same way and store the printers' own spelling (`X1C`); two alternatives for one model in different case are refused as the same printers. A pipeline aimed at a model class in lower case found no printers.
+  - **A reprint through the webhook** (`/webhook/queue/add`) of a print sent through the virtual printer ignored the AMS slots the slicer had picked, so the scheduler matched slots by colour and type again. It now reuses them on the printer they were picked on, as a reprint from the UI does.
+  - **A multi-plate file queued without a plate** (`/library/files/add-to-queue`, `POST /queue/`) prints its default plate, but the scheduler required every plate's filament, so a printer with that plate's filament loaded was never chosen, or the job was held for another plate's filament. The plate that prints is now stored on the job.
+- **Queue edits made while the queue is being worked through are kept** — The scheduler works on the queue as it read it at the start of each round, and a round can take minutes while a smart plug powers a printer on. A job edited in the meantime lost the edit: an AMS mapping set by hand was overwritten, a job moved to another printer (or an "Any model" job pinned to one) could still be sent to the printer the round had picked, and a new plate could be mapped from the old plate's filaments. A job edited during a round now waits for the next one, which reads the edit. A bulk edit likewise leaves a job that started dispatching while the edit was checked, instead of changing its printer or cost centre under the upload. While waiting for a printer to power on, the scheduler no longer keeps a database transaction open.
+- **A print's filament and cost are charged with its own plate and AMS mapping** — Bambuddy remembers per archive which plate and AMS slots a dispatch uses, for the usage and cost charged when the print ends. Three cases used the wrong ones:
+  - **A whole file queued after one plate of it** was sent but never started (refused, or the printer stayed idle) was charged as that plate, with its slots.
+  - **A plate that failed to send** wiped the plate number and slots of another plate of the same archive still printing, whose end then charged the whole file.
+  - **A reprint dispatched without a slot mapping** could be charged through the previous run's slots, when that run's printer had reported its own mapping.
+- **A timelapse goes to the print it belongs to** — Several cases gave an archive the wrong video, or none:
+  - **The next plate or a reprint started meanwhile:** the printer's timelapse is looked for for some minutes after a print ends. When the same archive started printing again in that time, the previous print's video went to the new print, and the new print's own video was then never imported. The search now stops once the archive is printing again, and nothing is attached or deleted from the printer.
+  - **Scan for Timelapse on an older archive** (from before the video list was recorded at print start) could pick a video another archive already has, for instance by a short name like "1" that every video's timestamp contains. Those are now left out of the automatic match, and are still offered for picking by hand.
+  - **Both the external camera's and the printer's timelapse on:** the archive keeps the external camera's video, as before, but the printer's video it replaces is now deleted instead of staying in the archive folder unused. A camera video stitched for a print whose archive is already printing again (the next plate, a reprint) is no longer attached to the new print.
+- **Archives pages show a timelapse as soon as it changes** — Only the automatic scan told other open pages; a video picked or uploaded by hand, the external camera's, the conversion to MP4 and deleting one showed only after a reload.
+- **An archive cost set through the API, or by Recalculate costs, reaches the statistics** — Setting an archive's cost (`PATCH /api/v1/archives/{id}`) or **Recalculate costs** changed the archive only, while statistics, project totals and batch sums read the cost from the print log, so they kept the old figure. A completed print that had the archive's cost now gets the new one too; a print priced from its own spools, and a failed or cancelled one, keep theirs. **Recalculate costs** still recalculates a cost that was set by hand, as before. A negative cost, or one over 1,000,000, is refused.
+- **A request with `Infinity` or `NaN` in a field that fails validation answers 422** — Such a request ended in a server error, because the error response repeats the value sent and JSON can't hold it.
+- **Editing an archive while it is being reprinted leaves the previous print alone** — Its outcome, failure reason, verdict and filament weight went onto the previous print's log entry, rewriting that print's result in the print log and failure statistics. The edit now changes the archive only.
+- **Archives made without their 3MF show their colours and AMS-HT filament** — When a print's 3MF couldn't be downloaded at start, the archive took its filament from the printer's AMS report. Colours were stored as the printer reports them (`FF8800FF`), so their swatches on the Archives page stayed blank and the colour filter listed them apart from the same colour of other archives; they are now stored as `#FF8800`, like an archive with its 3MF, and existing archives and their print log entries are repaired on startup. Filament the print took from an AMS-HT was not found, so a print using only an AMS-HT got no filament.
+- **An archive whose 3MF arrives later is priced from its spools** — Such an archive started with the default filament price, having no 3MF to estimate from at print start, and kept it when the 3MF was found during the print. It is now estimated from its spools then, as at print start; a 3MF found only as the print ends leaves the pricing to the print's end, as before.
+- **An archive shows the nozzle temperature of the filament its plate prints** — The temperature was taken from the project's first filament, so a plate printing only PETG showed the PLA temperature of filament 1. It is now that of the plate's first used filament.
+- **A print stopped while its printer was offline counts in the print log, and its real end replaces the stop** — When **Stop** couldn't reach the printer, the queue item and archive were closed as cancelled, but no print log entry was written, so the run was missing from the print log and failure statistics, and the Archives page kept showing it as printing until reloaded. The stop now writes the cancelled run and updates the Archives page. Should the printer report the print's real end on reconnect, that outcome replaces the cancelled entry, and the archive no longer shows **Completed** with the reason "Cancelled by user".
+  - **One entry per run:** a printer reporting the same end twice (after a reconnect) logged the run twice, doubling it in the statistics. The repeat is now left out.
+  - **A reprint's reason:** an archive whose earlier run failed kept that failure reason when a reprint completed. Each run's end now sets the archive's reason, or clears it.
+- **A print Bambuddy didn't start is matched to its own archive** — At its end, such a print was matched to an archive whose name merely contained its name, so finishing "Clip" could complete (and bill) a "Cable Clip" still marked as printing; `_` in a name also matched any character. It now needs the same name, allowing for the printer writing spaces as underscores, cutting long names short, or reporting the file name for a print started on its screen.
+- **Print start picks the print's own 3MF from the printer's folders** — When the 3MF couldn't be downloaded directly, Bambuddy searched the printer's folders and took the first file containing the print's name, so "Cube" could be archived with "Cube_large.3mf". A file named for the print is now taken, in whichever folder, and of several files that only contain the name, none.
+- **The A1, A1 Mini, P1 and A2L built-in calibration is not archived as a print** — These printers report their calibration run as a bare `auto_cali_for_user_param.gcode`, not under the system path other models use, so Bambuddy archived each run as a print (without a 3MF) and announced it. It is now recognised as the printer's own job. The printer kill switch still stops it on these models, since nothing tells it apart from a file given that name: switch the kill switch off to calibrate them.
+- **A notification channel limited to one printer gets that printer's events only** — A job added for **Any <model>** or not yet assigned, and any job waiting (for filament, a plate, a printer), were sent without a printer, so no printer filter applied: every channel limited to a printer received all of them, other printers' included. A job that waits on a printer now names it, and reaches that printer's channels; one with no printer yet goes to channels set to all printers, and a channel limited to one printer hears of it once it is assigned to or started there. A power alert from a plug linked to no printer likewise goes to channels set to all printers. Events about the whole setup (the queue finishing, stock, app messages) still reach every channel.
+- **Queue drying pre-dries in blocking mode** — With **Wait for drying to complete** on, a new drying cycle was refused on any printer with a pending job, scheduled ones included, so queue drying never dried anything ahead of a scheduled print. Only a job that could start now keeps a new cycle off; a printer whose job is scheduled for later is dried ahead of it, and the job still waits if the cycle has not finished by then.
+- **An "Any model" job waiting for a drying printer says so** — It read "Busy: X1C-01", as if that printer were printing; it now reads "Busy: X1C-01 (drying)", as a job for that printer does.
+- **An H2D shows the right active slot after a filament change** — An H2D reports its active slot as a bare number and names the AMS in each nozzle's own data, often in the same message. That message's AMS part was read first, against the previous message's nozzle data, so after a swap the card (and usage tracking) could keep pointing at the old slot until the printer reported its slot again. The nozzle data is now read first, and nozzle data from an earlier message no longer overrules the slot the printer just reported.
+- **Home Assistant "Run when printer turns off" scripts run when Bambuddy switches the printer off** — Linked scripts ran only when someone switched the printer's plug by hand. Auto-off after a print or drying (also when resumed after a restart), the plug's schedule and AI failure detection turned the printer off without them, and the schedule turned it on without its "on" scripts. They now run on all of these. "On" scripts keep running at every print start as before. For these automatic switches only a plug that powers the printer counts, the scripts run once when two plugs feed the printer, and a script whose **Enabled** toggle is off does not run (by hand either). Failure detection no longer tries to switch off a script.
+- **A plate check that could not run says so** — When the camera gave no image, the analysis failed or there was no calibration to compare against, the check before a print reported an empty plate, logged "Plate is empty" and told nobody. The print still goes ahead, but users who can control printers now get a warning with the reason, the log records it, and **Check Plate** shows it instead of "Plate appears empty".
+- **The archive recovery download gives a large 3MF time** — The retry that fetches a print's 3MF when the first download failed gave up after a flat 90 seconds, so a large file on a slow SD card never made it. Once the printer reports the file's size, the deadline follows it (up to 15 minutes), as the first download's already did.
+- **A broken MakerWorld download is not kept** — Only the HTTP status was checked, so a truncated file or an error page served as a success was stored as the model's 3MF. It is now refused unless it is a readable 3MF.
+- **An upload the printer never confirmed is checked** — When the printer's "transfer complete" reply does not arrive (an H2D can take over 30 seconds), the upload still counts, but its size is now read back on a new connection and a mismatch is logged as an error.
+- **The queue bulk edit says when it changed nothing** — The result was a neutral, English-only message even when no job was updated. It is now translated, and shown as an error when nothing was updated and as a warning when some jobs could not be changed.
+- **CallMeBot gets the phone number as written** — The phone number and API key went into the request unencoded, so "+49 151 ..." arrived without its "+".
+- **A plate printed only from the external spool starts without the AMS on multi-plate files too** — The queue already sends such a plate with the AMS off (#3087), but when another plate of the same file used an AMS slot, the print command turned the AMS back on and the printer refused the job, also for a job whose AMS was already switched off. The plate's own answer now stands.
+- **A Silk spool no longer takes a slot's Matte preset** — A spool without a slicer preset reuses the filament preset already in the slot when the material matches, to keep a calibrated preset, and so does **Configure Slot** for a preset without a filament id. That now also requires the finish to match when both name one (Basic, Matte, Silk, HF, ...); otherwise the slot gets the generic preset for the material and finish, such as Generic PLA Silk for a Silk+ spool. The generic fallback picks the finish's own generic in Spoolman mode too (Generic PLA Silk, PLA High Speed, PETG HF).
+- **A virtual printer for any printer of a model keeps the AMS on** — It stored the slicer's AMS on/off with the job. The slicer was looking at the virtual printer, which has no AMS, so that choice did not describe the printer the job went to. Only a virtual printer with a target printer keeps it now.
+- **The K-profile filament names from Bambu Cloud read a preset's id from either place** — The name lookup read a cloud preset's filament id only from the response envelope, while slicing also looked inside the preset itself; a preset with the id only there showed its raw id.
+- **A K-profile chosen on purpose is no longer undone when the AMS reports a change** — When a spool with a stored K-profile sat in a slot, an AMS report that changed the slot (its remaining amount, for example) set it back to the stored profile, so one picked in Bambu Studio or on the printer's screen did not last. That re-apply is gone: the existing lost-profile check puts the stored profile back when the slot has lost its selection (the default K after a reset or power cycle) while the printer is idle, and leaves a deliberately chosen profile, a chosen **Default** and the other nozzle's calibration alone.
+- **K-profile commands no longer change the shown nozzle size** — The printer's answer to saving, deleting or selecting a K-profile names the nozzle size of that profile, and Bambuddy took it as the installed nozzle. Only the printer's status updates the nozzle now.
+- **A K-profile save says when the printer did not confirm it** — A save, batch save or delete the printer did not acknowledge was reported as done. It now says the profile was sent but not confirmed (older firmware never answers), and an import or bulk delete says how many of its writes went unconfirmed. An edit on a single-nozzle printer, which removes the profile and adds it again, reads the printer's table back and fails when the edited profile is not there.
+- **Stopping drying says the command was sent** — The toast said "Drying stopped" as soon as the command went out; it now says "Stop command sent".
+- **A skip the printer refuses or never applies is logged** — Skipped objects were marked skipped the moment the command was sent. A refusal from the printer now takes them back off the skipped list and is logged with the printer's reason, and a skip the printer has not listed after a minute is logged as unconfirmed.
+- **The printer card shows a nozzle change, firmware update or developer-mode switch at once** — These were left out of the live updates and only reached the card with the 30-second refresh or a page reload.
+- **The print slot labels on AMS slots show up** — While a print runs, each AMS slot the print uses is meant to carry a small "P1 / P2" label, so a job sent to a printer with the wrong filament stands out. The labels read the print's mapping from the printer's own status, which almost never carries it, so they rarely appeared. Bambuddy already records the mapping from the print command, and the labels now use it. A print started from the printer's screen after a job that never started (refused, or cancelled before printing) no longer inherits that job's mapping, which usage tracking read as well.
+- **Drying no longer offers spool rotation on an AMS with filament in the feed tube** — **Rotate spool during drying** was disabled only for the tray state that means "loaded" on some firmware, so on others it stayed available and the printer refused the drying start. The slot the printer reports as loaded, and on two-nozzle printers the slot each nozzle is fed from, now count too.
+- **The run-out hint names the slot on two-nozzle printers with several AMS** — When a print paused for filament, the hint often said to check the printer screen, because the slot number alone does not say which AMS it is on. The AMS wired to the active nozzle now settles it; with the Filament Track Switch, where an AMS can feed either nozzle, the hint still asks.
+- **A regular AMS is never shown as an AMS-HT** — An AMS was taken for an AMS-HT when it reported a single tray. The unit number (128-135 for AMS-HT) now decides, on the printer card and its slot labels ("HT-B" became "AMS-B"), in the AMS Filament Backup view, print mapping, the slicer's loaded-spool list and the queue. A regular AMS that reports only some of its trays shows each one in its own slot rather than in list order.
+- **A print stopped by the printer kill switch is archived as cancelled** — The printer reports a stop as failed, and only stops made from Bambuddy were turned into cancelled, so every print the kill switch stopped went into the failure statistics as a failed print with no reason. It is now cancelled, with the reason **Stopped by the printer kill switch**, which the archive editor also offers.
+- **A queue job stopped while its printer was offline is counted as a user cancel** — Its archive stored an English sentence as the failure reason, which showed untranslated and counted as a cause of its own in the failure statistics. It now stores **User cancelled**, like every other stop; existing archives are converted on startup.
+- **Saving the SpoolBuddy backend config says it needs a restart** — The device writes a new backend URL or API token to its config file but keeps using the old ones until its daemon restarts, while the settings page only said "Config queued." It now says a restart applies it and offers **Restart Daemon**, which waits until the device has taken the config so the restart cannot replace it.
+- **A re-sliced archive reads as not printed and shows a cost** — Re-slicing an archive created a new archive marked "completed", so it appeared under **Printed** and was counted as a finished print although nothing had printed. It is now "archived" (shown as **Not printed**), like any file archived without printing, and it shows an estimated filament cost like other archives with grams.
+- **A reprint of a single-plate archive no longer leaves the old timelapse behind** — A timelapse attached by hand, or before Bambuddy recorded plates, was always kept as an unlinked file on a reprint, since on a multi-plate archive it can be another plate's only copy. On an archive with one plate there is no other plate, and it is now deleted with the run it belonged to.
+- **A GitHub backup restore checks the numbers it restores** — Grams, costs, energy, print times and spool-usage weights were copied from the backup as they were, so a damaged or hand-edited backup could put text, negative or absurd values into the totals. Anything that is not a valid number (grams at most 100000, as the archive editor allows) is left empty, and the restore summary says so. A quantity of 0 (a ruined plate) is kept instead of becoming 1.
+- **A print resumed after a restart keeps its plate height** — Each way Bambuddy picks up a running print (new archive, expected print, restart resume) remembered it under a different set of names. When the printer later reported a name that path had left out, the finished print was still found but lost its binding, and with it the height the plate restore uses after a restart. Every path now remembers the same names.
+- **An "Any model" job gets a fresh nozzle mapping on a nozzle-rack printer** — A nozzle mapping stored on a model-based job (through the API or an order's plate spec) was sent unchanged to whichever H2C picked it up, instead of the rack positions of that printer. It is now worked out for the printer that takes the job. On printers without a rack, such as the H2D, where the mapping means left and right nozzle, it is kept.
+- **A used weight entered for a new spool is kept** — Adding a spool (one, several with **Add copies**, or from a CSV) left its weight unlocked even when a used weight was entered, so the automatic AMS sync could overwrite a part-used spool's weight on its first pass. A used weight entered at creation now locks the spool, as editing the weight already did. A negative used weight is refused; more than the label weight is still accepted, since a mis-labelled spool really can give more.
+- **Spoolman's "last used" means a print used the spool** — Every AMS sync stamped *last used* on every loaded Spoolman spool, as did clearing a removed spool's location and weighing on the SpoolBuddy scale. Only print usage sets it now (through Spoolman's own usage endpoint), as in built-in inventory.
+- **The AMS slot K-profile picker accounts for the nozzle's flow** — On printers that report a high-flow or standard nozzle (H2-series), the picker offered and pre-selected K-profiles measured on the other kind, which are not applied when the slot is configured. They now sit under **Other**, and every profile shows its flow ([HF] / [S]), as in the spool form.
+- **Printer temperature history retention can be set, and no retention setting can wipe history** — How long the nozzle, bed and chamber temperature history is kept had no setting in the interface; it now sits next to the AMS history under **Settings → Filament → History Retention**. Both settings take at least one day: 0 or less was accepted through the API and made the daily cleanup delete all history, and a value stored that way is now read as one day.
+- **Bug reports no longer keep the reporter's email** — Every report sent from Bambuddy was also stored locally with the email it was sent with, and nothing ever read or removed it. The email still goes with the report; the local record keeps only the outcome, and emails stored before are cleared at startup.
+- **Unused API pieces removed and the rest documented** — The firmware upload no longer broadcasts a WebSocket progress message nothing listened to (the page polls the upload status, as before); the old route that deleted every plate-detection calibration of a printer at once is gone (references are deleted one at a time from the plate-detection dialog); `/spoolbuddy/calibration` now opens the SpoolBuddy settings page, where calibration lives. The wiki now documents `POST /archives/upload-source` for slicer post-processing scripts and `PUT /local-presets/{id}`.
+- **Load and Unload work on an AMS-HT** — Clicking **Load** on an AMS-HT slot was refused with "tray_id must be 0..15 …": the printer card numbered the slot like a regular AMS (unit × 4 + slot, 512 for the first AMS-HT), and the printer's commands would have turned the AMS-HT into a unit it does not have. **Unload** while the AMS-HT was the loaded tray was addressed to that same non-existent unit. Both now address the AMS-HT by its own unit number, as Bambu Studio does, and **Unload** from an AMS-HT slot's menu is aimed at that slot. Loading from the A2L's AMS-Lite now sends the target Bambu Studio sends as well.
+- **A second Bambuddy on the same machine no longer cuts the camera every minute** — Bambuddy's once-a-minute cleanup kills camera ffmpeg processes left behind by a crash or restart, and recognised them by their command alone. Another Bambuddy on the same host (a second install, a test instance) runs the very same command, so each one's cleanup killed the other's live camera once a minute and the view reconnected. Only an ffmpeg started by this Bambuddy, or left orphaned by an earlier run, is cleaned up now.
+- **A restart with a camera view open no longer logs errors** — A camera stream never ends by itself, so a restart closes it after 5 seconds. Each stream closed that way was logged as an ERROR with a full traceback ("Exception in ASGI application ... timeout graceful shutdown exceeded"), which looked like a crash in the log and in support bundles. It is now one INFO line per stream, and the "Cancel N running task(s)" line before them is INFO too; real errors are logged as before.
+- **Archive export includes the whole end day, and an invalid print speed is refused** — `GET /api/v1/archives/export?date_to=2026-10-15` read the date as midnight and left out everything printed that day; a date on its own now ends with that day, as in every other date filter, and a date and time with a UTC offset is converted to UTC. The Archives page was not affected, because it never sends dates. `POST /api/v1/printers/{id}/print-speed` with a mode other than 1-4 answered 500 "Failed to set print speed"; it now answers 422.
+- **AMS drying now applies to "Any <model>" queue jobs** — With **Wait for drying to complete** on, a job for "Any X1C" could still start on an X1C whose AMS was drying, and its drying was never stopped for the print; queue auto-drying also never dried anything for such a job. Now a model job passes over a drying printer for another of the model, and waits only while every one of them is drying. The printer it lands on stops drying like it would for a job pinned to it, and a scheduled model job has queue auto-drying dry every printer it could run on.
+- **Every way of adding a queue job now works like the print dialog** — Jobs added from File Manager's **Add to queue**, the webhook, a virtual printer, a pipeline run or an order's **Dispatch remaining** sent no "Job added" notification and no MQTT `queue/job_added` event; now every path sends them. Several jobs added at once are one notification per printer, such as "3 jobs (bracket, hinge, lid)", and MQTT gets one event per job. File Manager, the webhook and pipeline runs ignored the default print options (timelapse, bed levelling and the rest) and now use them. The copies an order dispatches lost their cost centre, so they were billed to none and never checked against its budget; they now keep it, and with billing on, dispatching is refused when you may not print with that cost centre or its budget cannot cover every copy. A `scheduled_time` sent to the API or webhook with a UTC offset, such as `10:00+02:00`, was stored as 10:00 UTC and the job started two hours late; the offset is now converted.
+- **Long prints keep their layer timelapse** — Stitching a layer timelapse was stopped after 5 minutes and its frames were then deleted, so a print with more than a few hundred layers on a Raspberry Pi lost its timelapse for good. Stitching now gets half a second per layer, from 5 minutes up to an hour. If ffmpeg fails anyway, or Bambuddy stops while it runs, the frames are kept for about 24 hours with an ffmpeg command to make the video by hand; when ffmpeg fails, the log names the folder. Without ffmpeg the frames are still deleted.
+- **An in-app update that hangs no longer blocks further updates** — A stalled `git fetch`, `pip install` or `npm install` left the update showing "downloading" or "installing" forever, and every new attempt was refused with "Update already in progress" until Bambuddy was restarted. Each step now has a time limit (5 minutes for git, 15 for pip and npm); a git or pip step that runs out of time ends the update with an error, and checking for updates again lets you retry. When pip or `git reset` is the one that ran out of time the new code may already be in place, so run the update again before restarting. A frontend build that runs out of time keeps the files shipped with the release.
+- **The plate picker downloads a printer's 3MF once** — Opening a 3MF in the printer's file browser downloaded the whole file again for the plate list and for every plate thumbnail, all at once: a ten-plate 60 MB file meant eleven full downloads, slow on any printer and slower still on an A1. The plate list, plate thumbnails and G-code preview now share one download and keep it for a minute (the 3D view still fetches its own copy).
+- **Printer file downloads tell a missing file from a failed one, and big files get the time they need** — Downloading a file from the printer, and the file browser's previews (G-code, plates, plate thumbnails), treated every failure as "File not found", including a slow transfer of a file just listed. Bambuddy now answers "not found" only when the printer says the file is gone, and otherwise that the download failed or took too long; the download's message and the timelapse messages show which. A file download was also held to 10 minutes whatever its size, so a 250 MB camera recording on a slow link always failed, and the previews to 5 minutes; both may now take as long as the file's size calls for, up to the 30-minute limit. A timelapse that could not be downloaded is reported the same way instead of as a server error.
+- **A timed-out printer download no longer runs on beside its retry** — When a timelapse download ran out of time, it kept going in the background while the next attempt opened a second connection to the printer. The transfer is now stopped first, and a download that already had the time its size allows is not retried.
+- **Large STL files no longer freeze Bambuddy while their thumbnail is made** — Uploading an STL, extracting a ZIP of STLs, generating missing thumbnails and scanning an external folder rendered each thumbnail in a way that held up everything else, so a high-poly model froze the web interface and printer updates for seconds or longer. Thumbnails are now made in the background, one at a time.
+- **A frozen external RTSP camera is reconnected** — When an external RTSP camera stops sending, `ffmpeg` can keep repeating the last picture, so the live view and `/camera/status` kept showing a picture that had stopped changing, and so did anything reusing the live frame (timelapse frames, finish photos, Obico). Like the printer's own camera, an external RTSP camera that sends the same picture for 20 seconds is now reconnected. A camera that is simply slow to change (a slow source, or a smart codec on a static night scene) is restarted at most once, after which Bambuddy waits three times the longest gap it has seen, up to 5 minutes; a picture that really does not change is re-checked every 5 minutes.
+- **A USB camera can be watched from several places at once** — Every viewer of an external camera opened its own connection to it. A USB camera can only be read by one program at a time, so opening the Camera page while a Cam Wall tile (or another browser) was already showing it gave a blank picture. External cameras now share one connection per printer, like the printer's own camera, which also means one connection per MJPEG or snapshot camera and one `ffmpeg` per RTSP or USB camera instead of one per viewer. As with the printer's own camera, the viewer who opens the connection sets its frame rate: with a Cam Wall tile already showing the camera, the Camera page gets the tile's 8 frames per second until everyone has closed it.
+- **Camera snapshots and the camera test no longer fail while someone watches the live view** — A USB camera can be read by one program at a time, so while a viewer was watching, every snapshot Home Assistant or the camera wall fetched from an external camera failed, and the external camera's **Test** button failed too. Snapshots now return the live view's frame, and the Test button reports the camera working from that frame. While the live view is still starting, a snapshot asks the caller to try again a moment later instead of taking the camera from the viewer (the camera wall and the camera page now retry once on their own); this now applies to the printer's own camera as well. An external camera with its own snapshot URL is still fetched from that URL.
+- **USB camera photos are no longer killed mid-capture** — A cleanup job that runs every minute stops leftover camera processes, and it did not know about the one-shot USB capture behind finish photos, timelapse frames and snapshots, so it sometimes killed one in progress. It also stopped any other program's `ffmpeg` reading a USB camera on the same machine; it now only stops Bambuddy's own.
+- **The camera Test button shows the resolution properly** — It read "Camera connected640x480"; it now reads "Camera connected (640x480)".
+- **Admins can remove a locked-out user's 2FA from Edit user** — The wiki described this, but Edit user had no control for it, so a user who lost their authenticator stayed locked out unless an admin called the API by hand. **Settings → Users → Edit user** now has **Disable 2FA**, which asks for the admin's own password (admins who sign in through LDAP or SSO usually have none and are not asked) and says whether the user had any 2FA to remove.
+- **The AMS drying button knows a printer cannot dry from the first page load** — After a page load and every 30-second refresh, the printer card didn't know the drying status or why drying wasn't possible, so a printer that cannot dry showed an enabled Start button until the next live update (the printer still refused the start). The page load and the live updates now describe AMS units and external spools in exactly the same way. A slot the firmware reports as only its number now also counts as empty from the first load, and the AMS temperature no longer switches between "24°C" and "24.0°C".
+- **The filament override picker keeps both external spools of a dual-nozzle printer without an AMS apart** — On an H2D or X2D feeding only from its external spools, identical spools on the left and right merged into one entry that was offered for both nozzles. The picker now uses the same dual-nozzle check as the print scheduler, so each external spool is offered only for the nozzle it feeds.
+- **A2L: filament used from the AMS Lite is charged to the right spool** — The printer names an AMS Lite tray by its local slot 1-4, and Bambuddy kept that number when it recorded which trays a print used, so the AMS Lite spool was never charged. With a regular AMS beside the AMS Lite, the usage went to that AMS's slot of the same number instead. A virtual printer with **Save AMS mapping** on stored the same local number for queued prints and their archives; with a regular AMS present, such a print was sent to that AMS instead of the AMS Lite.
+- **Proxy virtual printers no longer restart when another virtual printer is saved** — Creating, editing or deleting any virtual printer restarted every running proxy-mode virtual printer, dropping the slicer's connection and any upload in progress. They now restart only when their own settings change. Changing a printer's IP now restarts the proxy virtual printers aimed at it on the new address; before, they kept forwarding to the old one until a virtual printer was saved. Renaming a virtual printer now restarts it, in every mode, so the slicer sees the new name; before, a running virtual printer kept advertising its old name until Bambuddy restarted.
+- **Prints queued through a virtual printer keep the slicer's nozzle choices** —
+  - **Nozzle offset calibration** from the slicer, or the default from Settings → Workflow, is used. It always ran on "Auto".
+  - **H2C rack nozzles** picked in the slicer are kept only by a virtual printer bound to one printer. A virtual printer for any H2C sent those positions to whichever H2C took the job, whatever that printer's rack held; the dispatch now picks them from that printer's rack.
+  - **A job the queue moves to another printer**, or releases back to the pool, no longer takes the old printer's nozzle positions along.
+  - **AMS on or off** from the slicer is used when the slicer's options arrive in time; it was used only when they arrived late.
+  - **A slicer AMS mapping that arrives late** is no longer put on a plate of a multi-plate send that it cannot reach; that plate gets a computed mapping, as when the options arrive in time.
+- **Settings that were not saved or not used** —
+  - **Max copies per run** (Workflow) and the Docker compose directory used in the update instructions are saved. They changed on screen and were never sent, so a 100-copy pipeline run stayed refused at the old limit of 50.
+  - **Default printer** (General) is now selected in the print dialog when it opens, unless the file was sliced for another model or the job goes to a model rather than a printer. The setting was saved and never used.
+  - **Auto-archive prints** (General) now switches archiving off for every printer, for prints started outside Bambuddy. While it is on, each printer's own switch decides. Before, only the printer's switch was read. A printer's own calibration runs no longer send a "print started" notification while archiving is off.
+  - **Create Archive Entry When Printing** (File Manager) is removed. It was saved and never used: printing from the File Manager always creates an archive entry.
+- **A spool Spoolman creates from the AMS is the size of the roll** — a 250 g refill matched to an existing 1000 g filament was created as a 1000 g spool with 250 g left, showing as 75% used.
+- **An unregistered SpoolBuddy that is still running registers again on its next heartbeat**, as the documentation says. It stayed missing until the daemon or the Pi restarted, and the refused heartbeats filled its send buffer, which then held back everything queued behind them.
+- **SpoolBuddy installer, full mode, installed under /home** — the Bambuddy service it writes now starts. The service hid /home from itself and failed with 203/EXEC, as the main installer's did before #1685.
+- **Spoolman costs** —
+  - A failed or stopped print in Spoolman mode is logged in Print History with what its Spoolman spools cost for the filament it used, as with the built-in inventory. It was logged with no cost.
+  - The estimated cost of a queued library file is priced from the Spoolman spools in the trays it maps to. Every slot was priced at the default filament cost.
+- **Spoolman usage** —
+  - A failed or stopped single-filament print that switched to the AMS backup spool charges each Spoolman spool its share, as a completed print and the built-in inventory do. All of it went to the spool that ran out.
+  - Two copies of the same archive printing at once on two printers are both charged to Spoolman. The second print's start failed on the shared archive and that print was never deducted.
+  - A filament slot the print does not feed is no longer charged to the external spool, or to whichever AMS tray sits at that position, as with the built-in inventory.
+- **Spoolman slots** —
+  - Assigning or linking a Spoolman spool to an AMS slot is now checked against what the printer reports back, as with the built-in inventory. A printer that ignored the new filament showed no "not confirmed" notice, and later usage went to the wrong spool.
+  - Configure AMS Slot opens with the slicer preset set on the Spoolman spool when no per-printer-model preset applies, and the slot's K profile is matched under it. Before, the dialog opened with no preset and the K profile was matched against the tray's own filament.
+- **Spool slots** —
+  - Archiving a spool, one or in bulk, unassigns it from its AMS slots, with the built-in inventory and with Spoolman, and slots still held by spools archived earlier are released on update. The slot kept showing the archived spool and charged it the next print's usage. A Bambu Lab spool's tag still in the AMS after archiving is not given to another spool or turned into a new one. With Spoolman, a spool feeding a running print keeps its slot until the print's usage is reported.
+  - Deleting a Spoolman spool removes its slot assignments too, and a spool archived or deleted in Spoolman itself releases its slots after the next print on that printer. The slot kept pointing at a spool Spoolman no longer had.
+  - Printer cards and slot lists update right away when a Spoolman spool is archived, deleted or unassigned.
+  - **Sync** and **Sync all** in Settings → Spoolman no longer unlink a slot that reads empty while its printer is printing (a runout), or one the AMS still reports as holding a spool.
+  - The spool list in the Assign dialog updates right after a Spoolman assignment, so the spool just assigned is no longer offered for a second slot.
+  - **Restore** on an archived spool inside an expanded group row of the inventory table works. It did nothing.
+- **Spool weights from the AMS** —
+  - **Sync Weights from AMS** and **Sync Spoolman Weights from AMS** leave the spools of a printer that is printing alone, and say how many. Synced halfway through a print, that print's usage was counted twice at completion.
+  - A spool weighed on the SpoolBuddy scale is locked like one whose weight was set by hand, so the automatic AMS remain% sync no longer overwrites the measured weight.
+  - **Sync Weights from AMS** now also updates locked spools and releases their lock. Nothing else could release it, so a spool whose weight was once set by hand was skipped by the sync for good.
+- **SpoolBuddy** —
+  - The Write Tag page no longer stays on "Writing tag..." when the tag was written but could not be linked to the spool. It shows why: Spoolman not set up, linking in Spoolman failed, or the spool no longer exists.
+  - A scanned tag that cannot be looked up because Spoolman is unavailable, or because the lookup failed, now says so in the kiosk status bar for a few seconds. Before, nothing happened.
+  - With Spoolman switched on and its URL refused by Bambuddy's address check, tag lookups, tag writes and scale weights no longer fall back to the built-in inventory, where they found or changed a different spool. They report Spoolman as unavailable, and Write Tag says so. Switched on without a URL still means the built-in inventory, as everywhere else.
+  - Removing a SpoolBuddy device updates the device list right away.
+- **Archive status in Edit Archive** — the list offers Completed, Failed, Aborted and Cancelled. Choosing "Cancelled" used to save "aborted", which the statistics count as a failure, and a cancelled archive opened showing "Completed". An archive can no longer be set to "Printing" by hand, which had it closed again at the next reconnect as a missed print, with notifications and an extra Print Log entry. The archive card labels an aborted print "aborted" and now also marks a cancelled one, which looked like a normal completed print. Moving an aborted print to Cancelled keeps its recorded reason; marking a cancelled print Completed clears it.
+- **Archive costs** —
+  - **Recalculate Costs** and **Rescan** price one print of the archive. An archive printed three times was given the cost of all three. Usage recorded without a price is priced at the default filament cost instead of counting as free. Print History and the statistics still count every print with its own cost.
+  - Editing an archive's filament weight now reprices it, on the card and on the print it was copied to.
+  - A spool without a price is charged at the default filament cost when the print completes. With the default never saved, completion priced it at nothing while the Settings page showed 25 per kg, and Recalculate later used 25.
+- **A print of a file whose last print was never closed gets its own archive** — an archive left on "printing" because its end was missed used to take over the next print of the same file for two hours, so that print got no history entry, timelapse or cost estimate of its own. A different job id from the printer now marks it as a new print, except when it is Bambuddy's own reprint of that archive.
+- **Filament of a print that spans a Bambuddy restart is no longer counted twice** — when the restart found no saved print session, or one of another print, the AMS remaining-% sync raised the spool's used weight during the print, and completion then added the print's full usage on top.
+- **A print whose dispatch failed no longer leaves its cost centre behind** — the next print of the same archive without a cost centre of its own was charged to it, instead of the user or the archive's cost centre.
+- **Which plate of a multi-plate file printed** —
+  - Print History logs the filament and cost of the plate that printed. A reprint of another plate of the same file used to log the first plate's estimate.
+  - Filament usage after a Bambuddy restart mid-print is charged for the plate the queue job printed, not for the plate of the archive's first print.
+  - A print started outside Bambuddy records its plate on the archive when the printer reports it, so reprints, usage and Print History use that plate instead of the first one or the whole file.
+  - The archive's thumbnail and details describe the plate Bambuddy sent, also when the printer reports only the file name or names another plate.
+- **Reprints and their archives** —
+  - A reprint that completes no longer keeps showing why the previous run of the same archive failed (for example a clogged nozzle).
+  - A queued job whose archive was deleted before it started is now archived anew when it prints, on a printer that archives its prints. Before, the print ran with no archive, no start notification and no usage tracking.
+  - A timelapse deleted while it was being converted to MP4, or replaced by a reprint's in the meantime, no longer comes back when the conversion finishes.
+- **Print dialog fixes** —
+  - **Print Anyway** in a print to several printers now only waves through the printers whose filament warning it answered. Before, it went onto every selected printer, so one that was never checked also skipped the scheduler's filament checks.
+  - A job with printer alternatives whose plate dropdown was left untouched showed **Plate 1** but was queued with no plate, so it asked for every plate's filaments and the whole file's print time and could find no printer.
+  - On H2D and H2C, the nozzle shown for each filament now comes from the chosen plate, not from all plates of the file, which could match trays on the wrong nozzle.
+  - A sliced 3MF named without `.gcode` (a plate exported from Bambu Studio) can now be dropped on a printer card or picked in **Upload for Print**. An unsliced 3MF is refused with a message saying so.
+- **Orders and groupings show as finished however their last job ended** — An order or grouping was only re-checked when a print completed, an item was deleted or one was edited. A grouping whose last job was cancelled or stopped, or whose jobs were cancelled because their library file was deleted, an order whose last run was closed by the stuck-print cleanup, and one whose last open job failed before it reached the printer or was deleted, stayed **active** until the next restart. They are now re-checked on every way a job ends.
+- **A job cancelled while it is being sent stays cancelled** — Cancelling a queued job during its upload (the edit dialog even asks you to cancel a job that is being sent) was overwritten when the upload then failed: the job turned into a failure, a failure notification went out, and the next job that needs the previous print to have succeeded was skipped. A failure is now only recorded for a job that is still waiting or printing, and the upload toast of a cancelled job closes instead of spinning. Likewise, a job stopped just as its printer turned busy is no longer put back in the queue, and a stopped job whose nozzle rack pick no longer fit no longer leaves its file on the printer.
+- **"Only if the previous print succeeded" looks at the right printer** — For a job queued for any printer of a model, the check ran before a printer was chosen, against jobs with no printer, so it nearly always let the job go, even onto a printer whose last print had failed; when it did trigger, the job was skipped for good. It now checks the printer it is about to use, passes over one whose last print failed and takes another of the model, and waits if every free one failed last. The queue then shows a **Resume after failure** banner for that model, which clears the gate on its printers, and the "job waiting" notification also goes out when a job that waited for a busy printer comes to wait on such a failure. On PostgreSQL, a job cancelled without an end time (cancelling an order, a pipeline or a kept order run) also counted as the latest print and hid a real failure before it; such cancels now record their end time, and the check no longer ranks a missing one first.
+- **A queued job moved to another printer no longer keeps the old printer's settings** — A job's AMS tray mapping, **Print Anyway** and nozzle choice are worked out for one printer. Moving jobs to another printer (bulk edit, or editing one job) kept them, so a job could print from the slot that held the right material on the old printer but another colour on the new one, and a **Print Anyway** given for the old printer skipped the new printer's filament checks. **Dispatch remaining** of an order also pinned every copy of a job queued for any printer of a model to the printer its latest run happened to land on, with that printer's settings, so the other printers of that model stayed idle. These settings now stay with the old printer and are worked out again for the new one; plate, AMS use, filament overrides and print options are kept. Bulk edit leaves jobs with printer alternatives alone, as editing one does, and a job it moves to a printer no longer also waits for a model.
+- **Pages work for users without the settings permission** — Users in a group without **settings:read** could not read the install settings, so pages that need a few of them fell back to defaults. With billing on, the print dialog hid the cost centre picker and the print was then refused for lacking one. The print dialog also lost its print option and stagger defaults, currency and filament preference. The queue showed the wrong order with shortest job first on, the File Manager lost **Slice**, filament warnings an administrator had turned off still appeared, adding an unknown spool from its tag put it in the built-in inventory even in Spoolman mode, and the theme, currency and forecast lead time fell back to defaults. These settings now reach every signed-in user, without the credentials **settings:read** also shows; G-code snippets are only reported as set or not.
+- **SpoolBuddy writes the Spoolman spool to the tag in Spoolman mode** — Built-in spools are kept after switching to Spoolman. When one of them had the same id as the Spoolman spool being written, SpoolBuddy wrote the built-in spool's material, colour and weight to the tag and linked the tag to that hidden spool, while reporting success. The Spoolman spool never got the tag, so scanning it later found nothing. Writing a tag now uses only the inventory in use.
+- **Budget held by prints that ended is released** — Before a print without a user (authentication off, or its user deleted while it printed) was charged to its cost centre, it kept its budget reservation for good, so the cost centre showed that much less budget left. At the next start, reservations of prints that have finished or were removed from the queue are released. Only the reserved amount is freed; charges already booked don't change.
+- **A pipeline run remembers how each copy ended** — A copy a slicer pipeline sent to the queue only showed its outcome while its queue entry existed. Once the entry was deleted (from the queue page, with its archive, or with its user) the run could no longer tell a printed copy from one that never ran: it stayed **dispatching** forever, and **Retry failed** could not tell what to retry. Each copy now keeps its final state (completed, failed or cancelled) when its queue entry ends, and removing a copy that hadn't printed yet counts as cancelled. Copies whose entry was already gone before this update show as **unknown**: never retried, and counted as finished, so the run completes.
+- **Deleting a spool keeps its usage history, and a new spool no longer takes over a deleted one's** — On PostgreSQL, deleting a spool deleted its usage history, so **Recalculate costs** replaced the real spool cost of its prints with an estimate. On SQLite the history stayed, but the next spool created could get the deleted spool's number and then showed its prints and consumption as its own. The usage history now stays on both databases without a spool, the prints keep their spool cost, and spool numbers are never given out again on SQLite. A Git backup restores such history as well. Supplier and material statistics leave out usage of deleted spools, as there is no spool left to count it for.
+- **Archives a spool was used for can be deleted on PostgreSQL** — With PostgreSQL, deleting an archive for good (deleting it with **purge from statistics**, the automatic archive purge with that option, or deleting a printer together with its archives) failed with a server error when a spool from the inventory had been used for that print. The spool's usage record now stays and only loses its link to the deleted archive, on both databases; on SQLite, usage records still pointing at an archive deleted earlier are cleared of that link at the next start. **Recalculate costs** no longer charges usage records without an archive link to archives by their print name: that added the spool cost of a deleted print, of a print made with archiving off, or of every earlier run to each archive with the same name. Archives without linked usage get the estimate from the filament price instead, as they already did when no such record existed (usage recorded before 0.2.1, or with archiving off).
+- **An id of 0 no longer slips past the checks when queueing, batching or creating a project** — Adding to the queue with an archive id of `0` next to a library file, creating a batch with an archive or library file id of `0`, and creating a project with parent `0` skipped the check that the archive, file or project exists and saved `0` as its id: a server error on PostgreSQL, a link to nothing on SQLite. `0` now means "none" there, as it already did when editing a project. Creating a batch also checks that its archive or library file exists, and refuses an unknown one.
+- **Archiving a virtual-printer upload into a project that does not exist is refused up front** — The project chosen when archiving a pending upload was saved without checking it. On PostgreSQL an unknown project failed with a server error after the file had already been archived; on SQLite the archive was saved with a broken project link. The project is now checked first and an unknown one refused with "Project not found", before anything is archived.
+- **A project's bill of materials no longer shows archives from printers outside the user's groups** — A BOM line can be linked to an archive through the API, and the BOM answered with that archive's name whichever printer it came from. A member of a group limited to some printers could read the name of any archive by linking a line to it, and everyone reading the project saw the names of archives from printers they have no access to. A link to an archive that does not exist was saved too (a server error on PostgreSQL). A BOM line now links only to an archive the user can see, and shows a linked archive only to users who can see it.
+- **Adding archives or queue jobs to a project keeps to the user's own printers and prints** — Adding archives to a project (**Assign to project**), removing them, and adding queue jobs took any archive or job by its id. A member of a group limited to some printers could link another team's archives and jobs to a project or unlink them, and the reply's count told them which hidden archives exist; any project editor could also relink other users' prints. These now change only the archives and jobs the user could edit directly: on their printers, and only their own unless they may edit everyone's (Operators: their own). Others are skipped like missing ones, and **Assign to project** now reports how many were actually added. An API key with project access still needs no archive or queue access for this, but changes only the archives and jobs of its printers that its owner could change.
+- **Editing an archive can no longer move it to a printer outside the user's groups** — A member of a group limited to some printers could move an archive of one of their printers to any other printer through the archive edit, into the history of a team they have no access to, although uploading an archive to such a printer was already refused. Any edit could also link an archive to a printer or project that does not exist (a server error on PostgreSQL, a broken link on SQLite). Such an edit is now refused with "Printer not found" or "Project not found". Moving an archive between the user's own printers, and removing its printer or project, work as before.
+- **A smart plug or virtual printer can no longer be linked to printer 0** — Adding or editing a smart plug and adding a virtual printer skipped the "Printer not found" check when the printer id was `0`. On PostgreSQL the save then failed with a server error; on SQLite the plug or virtual printer was saved linked to a printer that does not exist. Such a request is now refused with "Printer not found", as for any other unknown printer.
+- **No more firmware-check errors in the log while the internet is down** — Without an internet connection, every firmware check of every printer logged a full error for each Bambu Lab page it tried (measured: 12 errors and 9 warnings for three printers checked three times). The outage is now logged once per Bambu Lab site as a warning, the checks keep retrying quietly, and a line in the log says when the site is reachable again.
+- **Bambu Cloud being unreachable is reported as such** — When Bambu Cloud failed (no internet, a blocked or failing connection, an error or an error page such as a Cloudflare challenge), the cloud preset, device and firmware views answered with a server error (500), and pasting an access token answered as if the token were wrong (401). Signing in or entering the email code without a connection to Bambu Cloud also answered 401, as if the password or code were wrong. These now answer 502 (Bambu Cloud unreachable) with the cause, as the preset lookups already did; a pasted token counts as invalid only when Bambu Cloud itself refuses it. An expired session is answered as such (401, sign in again) instead of with a server error.
+- **Editing a smart plug can no longer save a plug that cannot be read back** — Adding a plug checks its values: a Tasmota plug needs an IPv4 address, a Home Assistant plug an entity of the right kind, an MQTT plug a topic, a REST plug an ON or OFF URL, and the name must not be empty. Editing one did not, so clearing such a field, switching the type without filling in the new one, or typing a hostname as a Tasmota address saved a plug that the smart plug list, the plug itself and the edit all failed on with a server error until it was fixed in the database. Such an edit is now refused with the same message as when adding a plug, and the plug stays as it was. Switching a plug to Tasmota on a printer that already has a Tasmota plug is refused as well (adding one already was); before, it gave the printer a second one, and adding a plug to that printer then failed with a server error.
+- **Creating or importing a project gives it a priority and status the project editor accepts** — Importing a project from a file without a priority gave it the priority `0`: on PostgreSQL the import failed with a server error, on SQLite the project got a priority the project editor itself refuses. Creating a project through the API stored any priority, and importing one any status, the same way (a value longer than 20 characters failed with a server error on PostgreSQL). An imported project now keeps a valid priority (low, normal, high or urgent) and status (active, completed or archived) and gets **normal** and **active** otherwise, from a file and from the JSON import alike. Creating a project with an unknown priority is refused with "Invalid priority", as editing one already was. Such projects could not be edited at all (every save, even of the name alone, was refused as an invalid priority or status); existing ones get **normal** and **active** at the next start. Restoring a backup from such an older SQLite install onto PostgreSQL no longer fails on them either: they get the same values while restoring.
+- **The "Password Reset" email template is used again** — Since password resets send a link instead of a new password, the reset email (self-service and admin) was always the built-in one: editing the "Password Reset" template under Notifications changed nothing, and its default text still promised a new password. The reset email is now built from that template, and Bambuddy adds the reset link at the end of it (and as a button); the template itself cannot place or contain the link. `{password}` and `{login_url}` are gone from this template. The default text is updated on upgrade unless you edited it; if you did, reword it for a link instead of a new password.
+- **A cost centre is charged for a print whose user was deleted while it ran** — When a user was deleted during their print, the print was never charged: the cost centre's ledger missed its cost, and its budget reservation stayed active for good, holding back that much of the budget. The print is now charged to its cost centre, also to the deleted user's own private one, which their personal prints go through, and its reservation is settled. A queue print is charged under the user who queued it, never under the owner of the archive it reprints. Ledger entries without a user now read "No user" instead of "Deleted user": with login turned off, prints on a cost centre are now charged too and never had a user. Deleting a user together with their items now also releases the budget their queued prints still held; a print of theirs still running is kept until it ends (without a user) and billed to its cost centre like any other. Only a print with no cost centre at all (started while billing was off) has nobody left to charge.
+- **Deleting a printer no longer deletes an archive of a print starting at that moment (PostgreSQL)** — If a queue or reprint print started on the printer while it was being deleted, the delete could take the print's archive with it, even with "keep archives" and even when the archive belonged to another printer or a virtual printer queue. The delete now locks the printer while it runs, so such a print start waits and is turned away, and its archive stays as it was; this is logged as a print start for a deleted printer, not as an error, and sends no start notification. An archive that could not be written no longer leaves its copied 3MF behind in the archive folder.
+- **A printer deleted while its print was starting no longer gets an archive or tracking** — When a print starts, Bambuddy downloads its 3MF from the printer, which can take minutes. A printer deleted during that download still got an archive afterwards: on SQLite one that showed "printing" forever, since the deleted printer would never report the end, along with a layer timelapse and tracking for it; on PostgreSQL the archive was refused and an error logged. Bambuddy now checks that the printer still exists once the download is done, and again once the archive is written; a queue or reprint print starting just as its printer is deleted is no longer attached to that printer either. Archives the delete was told to keep stay as they are. A queue print dispatched to a printer deleted during its upload no longer records its owner for that printer either.
+- **Closing one camera view no longer cuts off the others** — With an external RTSP or USB camera, closing one view of a printer (a Cam Wall tile turning to snapshot, the Camera page, the camera window's refresh) stopped that printer's camera stream for every other open view too, which then froze until it reconnected. Each view now names itself on its stream and its stop: the stop ends that view's own stream (at once, as before, so a USB camera is released when its view closes) and any stream that has stopped sending pictures, and leaves the other views' streams running.
+- **Closing or minimizing a camera view now ends its stream** — The camera window on the Printers page left its stream open when minimized (one more open connection per minimize and expand), and both it and the Camera page closed only the first picture they had shown: after a reconnect or a restart, closing them left the current stream running. An open stream keeps the printer's camera connection busy, keeps the chamber light on when "Turn on chamber light for camera" is on for that printer, and made the "stop camera" request on closing do nothing. Every picture a camera view drops now ends its stream. A reconnect also no longer counts one extra failed attempt toward the reconnect limit.
+- **Cam Wall tiles no longer freeze on their last frame** — When the server ended a live stream (the camera dropped, or a brief database hiccup during the stream's 30-second access check), browsers report nothing, so a Cam Wall tile kept showing its last frame until the page was reloaded. Live tiles now reconnect their stream every minute while the page is visible, so a camera that comes back shows a picture again within a minute. If the camera stays down, the tile shows "No signal" (a printer's built-in camera keeps its last picture until Bambuddy stops retrying it). A tile that leaves live mode (scrolled away, paused) now closes its stream too, instead of leaving the connection open. The streaming overlay, which already renewed its camera every minute, now closes the old connection first: a browser source left running for hours piled up one open camera connection per minute. A single failed access check no longer ends a stream either: it is checked again after 5 seconds, and only a second failure in a row ends it.
+- **SQLite no longer gives a deleted user's, API key's, printer's or archive's number to a new one** — SQLite reuses the highest freed id, so whatever still referred to a deleted user, API key, printer, print archive or camera token could end up referring to the next one created. Earlier fixes cleaned up after each such case; these tables now never reuse an id at all. Existing SQLite databases are converted once at start (the rows, columns and indexes are kept), and ids still referred to (a deleted printer's print history, for example) are skipped. PostgreSQL never reused ids itself; a SQLite backup restored on PostgreSQL (including one restored before this release), or a PostgreSQL backup restored on SQLite, now keeps those ids unused too.
+- **Taking away live updates or camera access through a group now ends them** — Only creating a dashboard's live connection or a camera link checked the permission for it. When an admin removed that permission from a group, removed a user from the group that granted it, or turned off an API key's status access, a dashboard already open kept receiving live updates and could reconnect for up to an hour, and an open camera view kept running, its link opened new views and snapshots, and a Cam Wall or Home Assistant camera token kept working until it expired. Those connections and views are now closed and the links refused once the permission is gone; the dashboard falls back to refreshing on its own as it does for users without live updates.
+- **An open camera view stops once the viewer may no longer watch** — A live camera stream checked its link once, when it started, and then ran for as long as the tab stayed open. After a password change or reset, removing someone's two-factor authentication, deactivating a user, a backup restore, disabling or expiring an API key, revoking a Cam Wall or overlay token, a group change that takes the printer away, or turning authentication on, a view already open kept showing live video. Streams now check every 30 seconds that whoever opened them may still watch that printer, and end if not. A view that is still allowed keeps running, including past the hour its link is valid for.
+- **A tab whose live connection was closed by the server now recovers instead of freezing** — When the server closes the live-update connection because of sign-in (your session ended, authentication was just turned on, or a temporary check failed), the page used to stop updating and stay on screen until reloaded. It now re-checks your sign-in: a tab whose session ended goes to the login page, and one that is still signed in reconnects. While its connection keeps being refused or can't be made it waits longer between attempts, up to five minutes when the server closes an accepted connection again and up to 30 seconds otherwise, instead of retrying every three seconds.
+- **Signing a user out everywhere now also closes their open dashboards** — A dashboard's live connection is checked only when it connects. Changing your password, a password reset link, an admin removing someone's two-factor authentication, deactivating a user and restoring a backup all make earlier sign-ins invalid, but a dashboard already open on the account, possibly someone else's, kept receiving live updates until it disconnected, and the link it connected with stayed valid for an hour to connect again. Those live dashboard connections are now closed and their links removed; your own tab reconnects after signing in again. Turning authentication on likewise closes the dashboards opened while it was off, which kept every printer, and disabling an API key, or one expiring, closes the dashboards it opened.
+- **A deleted API key's open connections and tokens no longer pass to the next key** — SQLite gives a deleted key's id to the next key created. Live dashboards opened with the key stayed connected, and the camera and WebSocket links it created stayed valid for up to an hour; both were tied to the key by id and took the new key's printers. Deleting a key, or a user with keys, now closes those connections and removes the links, and links of keys deleted before are removed at start.
+- **A printer added after a delete no longer takes over the deleted printer's print, notifications, camera and plate references** — SQLite gives a deleted printer's id to the next printer created, and Bambuddy kept much of this under that id until the next restart.
+  - **Prints and billing:** the new printer took over the owner of a print still running when the old one was deleted. Its prints were treated as started from Bambuddy, so the kill switch let them run, and its first finished print was credited to that user. A job sent to the old printer that hadn't started yet could do the same for a print with the same file name. A new printer that was already printing when added could also take over the old print's filament tracking, charging its filament to the deleted printer's spools.
+  - **Gates and notifications:** the plate-clear prompt could block the queue on the new printer. Pending "bed cooled" and offline notifications fired under its name, and the first print's milestone notifications could be skipped.
+  - **First connect:** the steps that run when a printer connects, such as reading its K-profiles, were skipped.
+  - **Camera and detection:** a viewer could get the old printer's live picture for a few seconds, a layer timelapse kept recording from the old camera, and Obico failure detection showed the old verdict.
+  - **Plate detection:** the old printer's reference pictures of its empty plate stayed, so the new printer showed as calibrated, showed those pictures and compared its plate against them. They are now removed with the printer, and when a printer is added under an id that still has some from an earlier delete.
+- **SQLite: deleting archives, library files, queue jobs, batches or projects no longer leaves references to them behind** — SQLite doesn't apply the database's "when this row is deleted, clear or delete what points at it" rules, and it gives the highest freed id to the next row created. So a queue job could end up printing a different archive or library file than the one it was made for, a new project could show a deleted project's files and folders, and archives kept the id of a deleted library file (a new file then inherited them in project progress). Every delete of these now applies the rules the way PostgreSQL does, and every start repairs what earlier deletes left behind: queue jobs of a deleted archive are removed, as deleting an archive already does, while those of a library file that is gone are cancelled or kept as finished, as a library delete does. Deleting a user now also clears their id in the print log, so those runs show under "no user". Deleting a printer together with its archives, and the archive purge, now leave an archive alone while one of its queue jobs is printing (the printer delete answers that it has to wait), as deleting a single archive already did. Archives stored before archive search existed are now added to the search index once; until then, editing such an archive could fail with "database disk image is malformed". PostgreSQL installs aren't affected, apart from the printing check.
+- **API: `null` for a field that can't be empty no longer causes a server error** — On most PATCH/PUT routes, `null` for a field leaves it unchanged. About a dozen routes (printers, smart plugs, queue items and bulk edits, archives, filaments, spools, notification providers, GitHub backup, Home Assistant sensors, maintenance, external links) wrote it to the database instead and failed with a server error when the field can't be empty, such as a name or a switch. They now leave it unchanged too, and the rest of the request is applied. `null` for a field that may be empty, such as a printer's location, still clears it. The web interface never sent such values; this affects API clients and scripts.
+- **Webhook routes answer a database problem with 503, not 500** — The `/webhook/...` routes checked API keys with their own copy of the check, which missed earlier fixes. A database error while checking the key, a key too long to hash or a damaged stored key came back as a 500. They now use the same check as every other route: 503 when the key can't be checked right now, 401 when it isn't valid. An expired key now reads "Invalid API key" there too, like everywhere else.
+- **The MQTT relay publishes what the wiki says it does** — Three topics were missing or wrong. `smart_plugs/energy` was never sent: with the relay connected, each enabled plug's power, today's energy and total energy now go out once a minute (without the relay no plug is read for this, and an MQTT plug that stopped reporting is left out). `smart_plugs/on` and `/off` only came from switching a plug by hand; auto-on, auto-off, schedules, the queue powering a printer on and failure detection switching a plug off now publish them too. `maintenance/alert` always said 0 for the current value and the threshold; it now has the hours (or days) since the item was last done, its interval and a `unit` field.
+- **PostgreSQL databases from older versions get the links a new install has** — A database created before v0.2.4.1 (or before v1.2.5.2 for the last one) was missing three links: from a print log entry to its archive and to the user who started it, and from an archive to the library file it was printed from. Deleting an archive, a user or a library file left their ID behind instead of clearing it, so such entries didn't show up under "no user", for example. The links are added on the next start, and IDs of things already deleted are cleared. A print log entry without an archive also stays that way now: every start used to attach it to the newest archive with the same name on the same printer, which could be someone else's. 51 columns, mostly settings flags, also accepted an empty value there or had a default only those databases had. They now match a new install, and an empty value gets the column's default, with two exceptions so nothing gains access: an empty API key permission becomes "off", and an OIDC provider without an email claim, which so far linked no accounts, gets auto-linking turned off instead of starting to link by email. Apart from that, SQLite installs are unchanged.
+- **Spoolman: colour, material and cost from the spool weren't applied on older PostgreSQL installs** — On a PostgreSQL database created before v0.2.4.8, the start snapshot of the AMS levels was stored as text instead of JSON and read back as text. Reporting usage to Spoolman then failed after charging the spools from the 3MF, with "Spoolman usage reporting failed" in the log. Prints without a 3MF charged nothing, and no archive got the colour, material and cost of its Spoolman spools. The column is converted on the next start. Two number columns also only had single precision on PostgreSQL databases older than them and are widened, keeping the values as entered: a spool's "Reset usage" baseline (databases created before v0.2.4.2) and a REST smart plug's energy total multiplier (before v1.2.5). SQLite installs aren't affected.
+- **Every install now gets the dependency versions that were tested** — Bambuddy's Python dependencies only had minimum versions, so each Docker build, install and update took whatever was newest that day: a fresh install could run library versions nobody had tested Bambuddy with (SQLAlchemy 2.1 arrived that way). They are now pinned in `requirements.lock`, which Docker, the installers, the update scripts and the tests all use, and newer versions are taken on deliberately, after testing. Nothing changes for an install today: the pins are the versions a fresh install gets now. A native install moves to the pinned versions with the update after the one that brings this change, since an update still runs the previous version of the update script.
+- **Native installs need Python 3.11 or newer** — Since v0.2.4.8 Bambuddy hasn't started on Python 3.10, while the installer, README and wiki still said 3.10 was enough. The requirement now says 3.11. The installer uses the system's `python3` when it is new enough and otherwise looks for a newer Python next to it: on Ubuntu 22.04, whose `python3` is 3.10, it installs and uses the `python3.11` package. An environment left from an older Python is rebuilt. The update scripts check the install's Python first and, if it is too old, stop before changing anything and link to the steps for moving it to 3.11. Docker and the Windows installer ship Python 3.13 and are not affected. Wiki updated.
+- **The webhook endpoints listed in Settings didn't exist** — **Settings → API Keys → Webhook Endpoints** listed endpoints for the status of all printers, pausing and resuming that Bambuddy doesn't have, and two others under the wrong path. It now lists the real ones: add to the queue, start the next queued job, stop, cancel, printer status and queue status. Wiki updated.
+- **A valid API key could be refused as invalid during a brief database problem** — When checking an API key hit a database error (a busy database, or saving the key's last-used time while it was locked), the request was answered "Invalid API key", so integrations such as Home Assistant or scripts were told their key was wrong. It now answers "Authentication service temporarily unavailable" (503), which a client can retry, and the error is logged. A wrong or expired key is refused as before.
+- **Errors were reported as "Authentication service temporarily unavailable" with login switched off** — Without authentication, any unexpected error in Bambuddy answered 503 "Authentication service temporarily unavailable", and the log blamed the authentication check, which sent troubleshooting the wrong way. Such errors are now reported as the internal error they are (500), and the error with its details now lands in `bambuddy.log` (and so in support packages), whether login is on or off. The protection that refuses requests when Bambuddy can't tell whether login is on stays as it was.
+  - **With login on, a brief database hiccup no longer signs you out:** while checking a sign-in, a database error answered "Invalid token", and the web interface took that as final, signed the user out and forgot the sign-in. It now answers "Authentication service temporarily unavailable", which the interface retries, and the error is logged.
+- **A print started outside Bambuddy could be reported to the owner of the previous queue job** — On a printer with **Auto-archive completed prints** switched off (in the printer's settings), a print Bambuddy didn't start (for example one started on the printer itself) has no archive. When it finished, failed or was stopped, the notification looked for any queue job this printer had finished in the last five minutes and treated the print as that job: its owner got the "your print is done" email for a print that wasn't theirs. It now uses only the queue job the print itself belongs to, so such a print isn't sent to anyone's personal email.
+  - **A queue job whose archive couldn't be found** at the end, when it finishes or fails, is still reported to its owner, and its duration (`{duration}`) is how long it actually ran instead of "Unknown". A job stopped from the queue page in that situation no longer sends its owner the "stopped" email.
+- **Smart plug power alerts were never sent** — A plug's **Power Alerts** (alert if the power is above or below a value) never reached any notification provider, and each time one was due, that request for the plug's status on the printer card failed. They also were only checked while someone had a printer page open.
+  - **Now checked in the background,** about once a minute, whether or not Bambuddy is open.
+  - **One alert per crossing:** you're told when the power leaves the range, and again only after it was back inside it, so a printer idling below a low value is reported once rather than every five minutes. The low value only counts while the plug reports itself switched on, so turning a printer off raises no alert (a plug that can't report its switch state gets no low alerts). Alerts go out as the **Printer Error** event ("Printer Error: Power High" / "Power Low"). Wiki updated.
+- **A project's timeline stayed empty while one of its jobs was queued or printing** — The **Timeline** on a project's page failed to load as soon as the project had a pending or printing job in the queue, so it showed no events at all, not even the finished prints. It loads again, and queued and running jobs show under their print name (or file name; a job for any of several printer models under its first file).
+- **Stopping or cancelling a print through the API failed with an error** — `POST /api/v1/webhook/printer/{id}/cancel` answered with an error and left the print running. `POST /api/v1/webhook/printer/{id}/stop` stopped the print but still answered with an error. This affected Home Assistant automations and scripts that use an API key; the Stop button in Bambuddy was not affected.
+  - **Now both stop the print and answer normally.** If the printer drops off before the command can be sent, they answer "Printer not connected" (503).
+  - **A print stopped this way now counts as stopped, not failed,** as with the Stop button: the "print stopped" notification goes out instead of "print failed", and the print is no longer mislabelled with a printer error such as a layer shift.
+  - **`…/stop` now stops a paused print too,** as `…/cancel` and the Stop button on the printer card do; it used to answer that no print was in progress.
+  - **"Stop Printing" in the HMS error dialog** now also counts as stopped rather than failed.
 - **Starting the next plate of a Send All deleted the previous plate's timelapse (#3275, reported by @dovmesiz)** — All plates sent together with **Send All** share one archive. When a print starts on an archive that already has a timelapse, Bambuddy clears it so the new run gets its own video and completion photo. It also deleted the old video file, so starting plate 2 deleted plate 1's. With **delete from printer after archiving** on, that was the only copy. The video is now deleted only when the same plate prints again. Another plate's video, or one attached by hand or before this release, stays in the archive folder and is removed with the archive. The card still shows the latest plate's video. An external-camera timelapse no longer overwrites a kept video of the same name.
   - **A queued print still running 2 hours after it was sent lost what Bambuddy stored for it at dispatch:** its plate, AMS mapping and cost centre, which the completion uses (for example for the per-plate totals in the notification). A clean-up of unused print registrations removed them mid-print. They are now kept until the print completes.
 - **A PP spool without a slicer preset was sent to the AMS slot with no filament ID (#3273, reported by @sgiffhorn)** — When a spool has no preset of its own, Bambuddy fills in Bambu's generic preset for its material. The table used for spool assignment had no entry for PP, PE, PCTG, PLA Silk or PLA High Speed, so those slots got an empty filament ID, while **Configure AMS Slot** used a separate, longer table. The backend now has one table with all five, matching the one the frontend already used. A slot already set to Generic PLA Silk or PLA High Speed still keeps it when a PLA spool without a preset is assigned, as before.

+ 71 - 7
CONTRIBUTING.md

@@ -109,8 +109,8 @@ Both docs repos can be edited directly in the browser, no `git clone` required:
 python3 -m venv venv
 source venv/bin/activate  # On Windows: venv\Scripts\activate
 
-# Install dependencies
-pip install -r requirements.txt
+# Install dependencies: the pinned versions that ship (see "Dependencies" below)
+pip install -r requirements.lock
 pip install -r requirements-dev.txt  # Dev/test dependencies (pytest, ruff, bandit, etc.)
 
 # Install pre-commit hooks
@@ -195,9 +195,38 @@ ruff format backend/
 ruff format --check backend/
 ```
 
+Types are checked with [basedpyright](https://docs.basedpyright.com/) (configuration in `pyproject.toml`). Errors that existed when the check was introduced are listed in `.basedpyright/baseline.json` and pass; a new one fails the check. The check runs against the pinned library versions in `requirements-typecheck.lock`, because other versions report different errors. It needs Python 3.11 or newer on Linux, macOS on Apple Silicon, or WSL (the lock is built for Linux; on Intel Macs some pinned packages have no wheels). `./test_backend.sh` runs it for you: it creates `.typecheck-venv` on first use when Python 3.11 is available (through `uv`, or as `python3.11`), and rebuilds it whenever the lock changes. Running the commands by hand, re-run the `pip install` after a lock change:
+
+```bash
+python3 -m venv .typecheck-venv          # Python 3.11 or newer
+.typecheck-venv/bin/pip install -r requirements-typecheck.lock
+.typecheck-venv/bin/basedpyright --pythonpath .typecheck-venv/bin/python
+```
+
+- **Fixed an error from the baseline?** A local run without new errors removes it from `baseline.json`; commit that change with your fix. (With a new error in the same run, the baseline is left as it is until that error is fixed.) CI never rewrites the baseline and fails if it still lists an error that is gone.
+- **Added or changed a dependency in `requirements.txt`?** Regenerate `requirements.lock` (see [Dependencies](#dependencies)) and then the type-check lock, with the command in `requirements-typecheck.txt`, in the same PR. If that moves the pin of a package already in the lock and the check then reports errors in code you did not touch, regenerate the baseline in the same commit (see `requirements-typecheck.txt`).
+- **Moved or edited code that has a baselined error?** The baseline matches each file's errors in order by rule and column span, not by line number. Editing such a line, re-indenting it (wrapping it in `if`/`try`/`with`) or reordering functions that hold baselined errors can bring them back as new ones. Fix them while you are there.
+
+### Dependencies
+
+`requirements.txt` lists what Bambuddy needs, with lower bounds. `requirements.lock` pins the exact versions, for every supported Python (3.11+) and platform, and it is what Docker, the installers, the update scripts and CI install: everyone runs the versions that were tested. Both files change together, and a test (`test_requirements_lock.py`) fails if the lock doesn't cover `requirements.txt`.
+
+Regenerating needs [uv](https://docs.astral.sh/uv/) (`pip install uv`):
+
+```bash
+# After adding or changing a dependency (keeps every other pin)
+uv pip compile requirements.txt --universal --python-version 3.11 -o requirements.lock
+
+# Moving every package to its newest version, on purpose (e.g. before a release)
+uv pip compile requirements.txt --universal --python-version 3.11 --upgrade -o requirements.lock
+```
+
+Then regenerate the type-check lock (command in `requirements-typecheck.txt`), install the new lock into your venv, and run the full test suite before committing.
+
 ### Frontend (TypeScript/React)
 
-We use ESLint for linting and TypeScript for type checking:
+We use ESLint for linting, TypeScript for type checking and knip for unused
+files and dependencies:
 
 ```bash
 cd frontend
@@ -207,8 +236,17 @@ npm run lint
 
 # Type check
 npm run typecheck
+
+# Unused files and dependencies
+npm run check:knip
 ```
 
+knip follows imports from the entry points (`index.html`, the Vite and Vitest
+configs, and `public/sw.js`, which the browser loads by URL). A file nothing
+imports, or a package nothing uses, fails the check: delete it, or add it to
+`frontend/knip.jsonc` with a comment saying why if something loads it in a way
+knip can't see.
+
 ### Pre-commit Hooks
 
 Pre-commit hooks run automatically on `git commit` and include Ruff linting/formatting, trailing whitespace fixes, YAML/JSON validation, and import shadowing checks. To run manually:
@@ -319,8 +357,8 @@ reprint eligibility. Archive reprint still needs `queue:create` before it can en
 The easiest way to run tests is with the provided scripts in the project root:
 
 ```bash
-./test_frontend.sh    # TypeScript check + ESLint + Vitest
-./test_backend.sh     # Ruff lint/format + pytest (parallel)
+./test_frontend.sh    # TypeScript check + ESLint + knip + Vitest
+./test_backend.sh     # Ruff lint/format + type check (if .typecheck-venv exists) + PostgreSQL upgrade check (if Docker is available) + pytest (parallel)
 ./test_docker.sh      # Full Docker build, unit tests, and integration tests
 ./test_all.sh         # All of the above (frontend → backend → docker)
 ./test_security.sh    # Security scans (bandit, pip-audit, npm-audit)
@@ -347,6 +385,32 @@ against — it exercises code paths that open their own sessions, and some of th
 write. Don't undo that override to "test against real data": point `DATABASE_URL`
 at a copy instead.
 
+### Database migrations (PostgreSQL)
+
+The test suite runs on SQLite only. `scripts/check_postgres_upgrade.py` covers
+PostgreSQL: it builds a database with older releases' code (the first release
+with PostgreSQL support and the newest stable one), upgrades it with the current
+code twice, and compares its schema with a fresh install. `test_backend.sh`
+runs it in a throwaway `postgres:16-alpine` container when Docker is available
+and the clone has the release tags (`git fetch --tags`); CI runs it too.
+
+A fresh install gets every table and column straight from the models, while an
+existing database only gets what the migrations in `backend/app/core/database.py`
+add. So a new column, constraint or index needs a migration that builds it
+exactly as the model does, including nullability and default; the check fails
+when an upgraded database ends up different.
+
+A migration that only fails with data in the database, such as a constraint
+that existing rows violate, gets a scenario in `SCENARIOS` in the same script:
+a fixed chain of old releases to build the database, rows to add, and a query
+that must find nothing wrong after the upgrade.
+
+`scripts/postgres_schema_drift.txt` can list known drift, each line an object
+and exactly how it differs; a listed object that starts to differ in another
+way fails like new drift, and so does a line that no longer drifts. The drift
+found when the check was introduced has been fixed and the list is empty: fix
+new drift with a migration rather than listing it.
+
 **Frontend** — tests use [Vitest](https://vitest.dev/) and are in `frontend/src/__tests__/`:
 
 ```bash
@@ -360,8 +424,8 @@ npm run test:coverage  # With coverage
 
 Pull requests trigger automated CI checks via GitHub Actions (`.github/workflows/ci.yml`):
 
-- **Backend**: Ruff lint + format check, unit/integration tests, pip-audit
-- **Frontend**: ESLint, TypeScript type check, Vitest tests, production build
+- **Backend**: Ruff lint + format check, basedpyright type check (against the baseline), unit/integration tests, PostgreSQL upgrade check, pip-audit
+- **Frontend**: ESLint, knip (unused files and dependencies), TypeScript type check, Vitest tests, production build
 - **Docker**: Full image build, backend/frontend tests in Docker, integration health checks
 - **Security**: CodeQL analysis, dependency audits
 

+ 3 - 2
Dockerfile

@@ -52,10 +52,11 @@ RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
 # pip is upgraded to >=26.1 first to close CVE-2026-6357 — the python:3.13-slim
 # base image ships pip 26.0.1, which runs its self-update check after installing
 # wheels (so a hostile wheel could hijack stdlib imports during install).
-COPY requirements.txt ./
+# requirements.lock pins the exact versions that were tested (see CONTRIBUTING.md).
+COPY requirements.txt requirements.lock ./
 RUN --mount=type=cache,target=/root/.cache/pip \
     pip install --root-user-action=ignore --upgrade 'pip>=26.1.2' \
- && pip install --root-user-action=ignore -r requirements.txt
+ && pip install --root-user-action=ignore -r requirements.lock
 
 # Copy backend
 COPY backend/ ./backend/

+ 11 - 6
Dockerfile.test

@@ -5,8 +5,10 @@ FROM python:3.13-slim AS backend-test
 WORKDIR /app
 
 # Install system dependencies for testing
+# procps: pkill, which a camera test uses to clean up its fake ffmpeg.
 RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
+    procps \
     && rm -rf /var/lib/apt/lists/*
 
 # Install Python dependencies including test dependencies.
@@ -14,10 +16,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
 # cache so the second build only does install work — the slow ~60-90s
 # fetch step from the first build becomes ~5s. Requires DOCKER_BUILDKIT=1
 # (already set in test_docker.sh).
-COPY requirements.txt ./
+COPY requirements.txt requirements.lock ./
 COPY requirements-dev.txt ./
+# Two installs, as in CI: the lock pins the newest ruff (requirements.txt only
+# has a floor) and requirements-dev.txt pins the one CI enforces, so a single
+# install would have two exact pins for ruff and fail. The second one wins.
 RUN --mount=type=cache,target=/root/.cache/pip \
-    pip install -r requirements.txt -r requirements-dev.txt
+    pip install -r requirements.lock && pip install -r requirements-dev.txt
 
 # Copy backend code
 COPY backend/ ./backend/
@@ -41,10 +46,10 @@ HEALTHCHECK NONE
 # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable
 # stdout I/O time and clutter test_docker.sh output. --tb=short still
 # gives full failure tracebacks when something breaks.
-# -n auto adapts to the host's vCPU count instead of hard-coding 30 —
-# on a 2-vCPU CI / VM runner, -n 30 spawns 30 Python processes fighting
-# for 2 cores, which is mostly IPC + import-thrash overhead.
-CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
+# -n 30: the test host's worker count. Not -n auto (with psutil installed
+# that counts physical cores only, 16) nor -n logical (the Docker host's 32
+# CPUs, more than this machine is meant to use).
+CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "30"]
 
 # -------------------------------------------
 # Frontend test stage

+ 2 - 2
README.md

@@ -599,7 +599,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 ## 🚀 Quick Start
 
 ### Requirements
-- Python 3.10+ (3.11/3.12 recommended)
+- Python 3.11+ (3.12 or 3.13 recommended)
 - Bambu Lab printer with **Developer Mode** enabled (see below)
 - **"Store sent files on external storage"** enabled in Bambu Studio/OrcaSlicer
 - Same local network as printer
@@ -757,7 +757,7 @@ git clone https://github.com/maziggy/bambuddy.git
 cd bambuddy
 python3 -m venv venv
 source venv/bin/activate
-pip install -r requirements.txt
+pip install -r requirements.lock
 
 # Run (--loop asyncio avoids a uvloop TLS bug that can truncate VP FTP uploads)
 uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio

+ 7 - 0
SECURITY.md

@@ -123,6 +123,13 @@ line; CI fails otherwise. (We use a standalone marker rather than
 `# noqa: ...` because ruff reserves the latter syntax for its own
 error codes.)
 
+One bounded exception: an open camera stream re-checks its viewer 30
+seconds after the last check that allowed it. Once a check is due, no
+frame goes out until it has run. If it cannot be read (it raises),
+frames continue for at most 5 seconds from when it began; then a second
+check runs (at once, if the first took longer), and the stream ends
+unless that one allows it.
+
 ### 3. No hardcoded fallback secrets
 
 Production secrets (JWT signing keys, encryption keys, OAuth client

+ 1 - 1
UPDATING.md

@@ -58,7 +58,7 @@ cd /opt/bambuddy
 sudo systemctl stop bambuddy
 sudo -u bambuddy git fetch origin
 sudo -u bambuddy git reset --hard origin/main
-sudo -u bambuddy venv/bin/pip install -r requirements.txt
+sudo -u bambuddy venv/bin/pip install -r requirements.lock
 sudo systemctl start bambuddy
 ```
 

+ 9 - 3
backend/app/api/routes/_oidc_helpers.py

@@ -16,8 +16,9 @@ from urllib.parse import urlparse
 from backend.app.api.routes._url_safety import (
     CLOUD_METADATA_HOSTNAMES,
     CLOUD_METADATA_IPS,
-    NUMERIC_IP_RE,
+    is_numeric_encoded_ip,
     unwrap_ipv4_mapped,
+    url_hostname,
 )
 
 
@@ -54,17 +55,22 @@ def assert_safe_public_https_url(url: str) -> None:
     if parsed.scheme.lower() != "https":
         raise ValueError("icon URL must use https://")
 
-    hostname = (parsed.hostname or "").lower()
+    hostname = url_hostname(parsed)
 
     # "https:///path" parses to an empty hostname; without this it reaches the
     # ip_address() ValueError branch and is accepted as a symbolic hostname.
     if not hostname:
         raise ValueError("icon URL must include a hostname")
 
+    # HTTP clients map other dot characters and fullwidth forms to ASCII
+    # before they connect: a host the checks below cannot read is refused.
+    if not hostname.isascii():
+        raise ValueError("icon URL must use an ASCII host name (punycode, xn--..., for international names)")
+
     if hostname in CLOUD_METADATA_HOSTNAMES:
         raise ValueError("icon URL must not point to a cloud metadata endpoint")
 
-    if NUMERIC_IP_RE.match(hostname):
+    if is_numeric_encoded_ip(hostname):
         raise ValueError("icon URL must not use numeric-encoded IP addresses")
 
     try:

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

@@ -59,6 +59,10 @@ class MappedSpoolFields(TypedDict):
     created_at: str | None  # None when Spoolman spool has no registered timestamp
     updated_at: str | None
     cost_per_kg: float | None
+    # What prints are charged per kg: the spool's own price, else its
+    # filament's catalogue price (SM-C8). Display only; the form edits
+    # cost_per_kg, so the catalogue price is never written onto the spool.
+    effective_cost_per_kg: float | None
     # Spoolman's native filament.article_number, surfaced as the internal
     # material number (#2870). Read-only in Spoolman mode — the number is
     # filament-level there and maintained in Spoolman itself.
@@ -240,6 +244,15 @@ def spoolman_cost_per_kg(spool: dict) -> float | None:
     return spoolman_price_to_cost_per_kg(spool.get("price"), spoolman_price_weight(spool))
 
 
+def _charged_cost_per_kg(spool: dict) -> float | None:
+    """The per-kg rate a print off this spool is charged at, from the same
+    function the charge uses, so the Inventory shows what prints cost."""
+    from backend.app.services.spoolman_tracking import _spool_cost_per_gram
+
+    per_gram = _spool_cost_per_gram(spool)
+    return round(per_gram * 1000.0, 4) if per_gram is not None else None
+
+
 def _extract_extra_str(extra: dict, key: str) -> str:
     """Extract a JSON-encoded string from a Spoolman extra dict.
 
@@ -539,6 +552,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "updated_at": created_at,
         # Spoolman's spool price is what the whole spool cost (#3194).
         "cost_per_kg": spoolman_cost_per_kg(spool),
+        "effective_cost_per_kg": _charged_cost_per_kg(spool),
         # Spoolman's filament.article_number maps 1:1 onto the internal
         # material number (#2870): both identify the purchasable product.
         # Trimmed for the same reason the schema validator trims the internal

+ 85 - 15
backend/app/api/routes/_url_safety.py

@@ -25,10 +25,13 @@ from __future__ import annotations
 
 import ipaddress
 import re
-from urllib.parse import urlparse
+from urllib.parse import ParseResult, urlparse
 
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
-# targets. Both guards reject these unconditionally.
+# targets. Both guards reject these unconditionally. The documented endpoints
+# of the major providers, so best effort by nature. Those inside
+# 169.254.0.0/16 are also covered by the LAN guard's link-local refusal;
+# 100.100.100.200 and the IPv6 ones are refused only through this list.
 CLOUD_METADATA_IPS = frozenset(
     {
         # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
@@ -37,6 +40,21 @@ CLOUD_METADATA_IPS = frozenset(
         ipaddress.ip_address("100.100.100.200"),
         # AWS IMDS IPv6
         ipaddress.ip_address("fd00:ec2::254"),
+        # Tencent Cloud
+        ipaddress.ip_address("169.254.0.23"),
+        # Scaleway
+        ipaddress.ip_address("169.254.42.42"),
+        ipaddress.ip_address("fd00:42::42"),
+        # Oracle Cloud IMDS IPv6
+        ipaddress.ip_address("fd00:c1::a9fe:a9fe"),
+        # GCP metadata server on IPv6 VMs
+        ipaddress.ip_address("fd20:ce::254"),
+        # Akamai / Linode metadata service IPv6
+        ipaddress.ip_address("fd00:a9fe:a9fe::1"),
+        # AWS EKS Pod Identity Agent IPv6 (its IPv4 address is link-local)
+        ipaddress.ip_address("fd00:ec2::23"),
+        # OpenStack metadata service on IPv6 networks
+        ipaddress.ip_address("fe80::a9fe:a9fe"),
     }
 )
 
@@ -49,21 +67,53 @@ CLOUD_METADATA_HOSTNAMES = frozenset(
     {
         "metadata.google.internal",  # GCP
         "metadata.goog",  # GCP short form
+        "metadata.google",  # GCP
+        # A bare "metadata" is completed to metadata.google.internal by the
+        # resolver's search domain on a GCP VM.
+        "metadata",
+        "instance-data",  # AWS, through the search domain
+        "instance-data.ec2.internal",  # AWS
+        "metadata.tencentyun.com",  # Tencent Cloud
+        "api.metadata.cloud.ibm.com",  # IBM Cloud VPC
     }
 )
 
 
-# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
-# for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
-# raises ValueError on these, so they slip past the IP-class checks if
-# not caught first. Used by both guards to reject up-front.
-NUMERIC_IP_RE = re.compile(r"^(0x[0-9a-f]+|[0-9]+)$", re.I)
+# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433`` for
+# 127.0.0.1, hex ``0x7f000001``, dotted hex or octal, short forms such as
+# ``127.1``) but Python's ``ipaddress`` raises ValueError on them, so they slip
+# past the IP-class checks if not caught first. Used by both guards to reject
+# them up front, see ``is_numeric_encoded_ip``. One label of such an address
+# is decimal, octal or hex.
+_NUMERIC_LABEL_RE = re.compile(r"^(0x[0-9a-f]*|[0-9]+)$", re.I)
+
+
+def url_hostname(parsed: ParseResult) -> str:
+    """The URL's hostname as resolvers see it: lower case, no trailing dot."""
+    return (parsed.hostname or "").lower().rstrip(".")
+
+
+def is_numeric_encoded_ip(hostname: str) -> bool:
+    """Whether *hostname* is an IPv4 address in a notation other than plain
+    dotted decimal: one number, dotted hex or octal, a leading zero, or fewer
+    than four parts. Resolvers read all of them as an address; the checks on
+    addresses only see standard notation.
+    """
+    labels = hostname.split(".")
+    if not all(_NUMERIC_LABEL_RE.match(label) for label in labels):
+        return False
+    try:
+        ipaddress.IPv4Address(hostname)
+    except ValueError:
+        return True
+    return False
 
 
 def unwrap_ipv4_mapped(
     addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
 ) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
-    """Return the underlying IPv4 for an IPv4-mapped IPv6 address, else return *addr*.
+    """Return the underlying IPv4 for an IPv4-mapped IPv6 address, *addr* without
+    its zone id, else *addr*.
 
     ``::ffff:127.0.0.1`` and similar mapped forms must be unwrapped before
     the per-class checks (``is_private``, ``is_loopback``, …) — otherwise
@@ -72,6 +122,10 @@ def unwrap_ipv4_mapped(
     """
     if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
         return addr.ipv4_mapped
+    # A zone id is ignored on connect to an address that is not link-local,
+    # but it makes the address compare unequal to the same one without it.
+    if isinstance(addr, ipaddress.IPv6Address) and addr.scope_id is not None:
+        return ipaddress.IPv6Address(str(addr).split("%", 1)[0])
     return addr
 
 
@@ -93,14 +147,17 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
       ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
       about returning a clear validation error at configuration time rather
       than an opaque failure at delivery time.
-    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
-      libc and browsers resolve these, but Python's ``ipaddress`` raises
-      ValueError on them, so they would slip past the checks below.
+    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``,
+      dotted hex or octal, short forms) — libc and browsers resolve these,
+      but Python's ``ipaddress`` raises ValueError on them, so they would
+      slip past the checks below. Host names not written in ASCII, which
+      HTTP clients map to plain ones before connecting, likewise.
     - Cloud-provider metadata endpoints — the high-value SSRF target, and
-      never a legitimate destination for any of these services.
+      never a legitimate destination for any of these services — and the
+      whole IPv4 link-local range they live in.
     - Multicast and unspecified addresses — pointless as a destination and
       indicative of misuse.
-    - IPv4-mapped IPv6 encodings of any of the above.
+    - IPv4-mapped IPv6 encodings and zone ids of any of the above.
 
     Symbolic hostnames are otherwise accepted without DNS resolution, matching
     the public-internet guard: resolution here would be both a TOCTOU (DNS can
@@ -112,7 +169,7 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
     if parsed.scheme.lower() not in ("http", "https"):
         raise ValueError(f"{label} must use http or https")
 
-    hostname = (parsed.hostname or "").lower()
+    hostname = url_hostname(parsed)
 
     # "http:///path" parses to an empty hostname. Never a valid destination,
     # and without this it falls through the ip_address() ValueError branch
@@ -120,10 +177,16 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
     if not hostname:
         raise ValueError(f"{label} must include a hostname")
 
+    # HTTP clients map other dot characters, fullwidth digits and similar
+    # forms to plain ASCII before they connect, so a host the checks below
+    # cannot read is refused rather than let through as a name.
+    if not hostname.isascii():
+        raise ValueError(f"{label} must use an ASCII host name (punycode, xn--..., for international names)")
+
     if hostname in CLOUD_METADATA_HOSTNAMES:
         raise ValueError(f"{label} must not point to a cloud metadata endpoint")
 
-    if NUMERIC_IP_RE.match(hostname):
+    if is_numeric_encoded_ip(hostname):
         raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
 
     try:
@@ -138,3 +201,10 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
 
     if effective.is_multicast or effective.is_unspecified:
         raise ValueError(f"{label} must not point to a multicast or unspecified address")
+
+    # IPv4 link-local is where cloud providers put their metadata endpoints,
+    # listed above or not; a LAN service has no reason to live there. IPv6
+    # link-local and ULA stay reachable, as real LAN devices use them: the
+    # metadata addresses there are refused one by one through the list.
+    if isinstance(effective, ipaddress.IPv4Address) and effective.is_link_local:
+        raise ValueError(f"{label} must not point to a link-local address (169.254.x.x)")

+ 0 - 33
backend/app/api/routes/ams_history.py

@@ -102,36 +102,3 @@ async def get_ams_history(
         max_temperature=stats.max_temp,
         avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp is not None else None,
     )
-
-
-@router.delete("/{printer_id}")
-async def delete_old_history(
-    printer_id: int,
-    days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
-):
-    """Delete old AMS history data for a printer."""
-    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
-
-    result = await db.execute(
-        select(func.count(AMSSensorHistory.id)).where(
-            and_(
-                AMSSensorHistory.printer_id == printer_id,
-                AMSSensorHistory.recorded_at < cutoff,
-            )
-        )
-    )
-    count = result.scalar()
-
-    await db.execute(
-        AMSSensorHistory.__table__.delete().where(
-            and_(
-                AMSSensorHistory.printer_id == printer_id,
-                AMSSensorHistory.recorded_at < cutoff,
-            )
-        )
-    )
-    await db.commit()
-
-    return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}

+ 40 - 5
backend/app/api/routes/api_keys.py

@@ -1,14 +1,18 @@
 import logging
+from datetime import datetime, timezone
 
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes.notifications import forget_app_senders
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ensure_printers_exist
 from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
 from backend.app.models.user import User
 from backend.app.schemas.api_key import (
     APIKeyCreate,
@@ -53,6 +57,8 @@ async def create_api_key(
             detail="can_access_cloud requires authentication to be enabled (per-user cloud tokens)",
         )
 
+    await ensure_printers_exist(db, data.printer_ids)
+
     # Generate the key
     full_key, key_hash, key_prefix = generate_api_key()
 
@@ -168,6 +174,7 @@ async def update_api_key(
     if data.can_send_notifications is not None:
         api_key.can_send_notifications = data.can_send_notifications
     if data.printer_ids is not None:
+        await ensure_printers_exist(db, data.printer_ids)
         api_key.printer_ids = data.printer_ids
     if data.enabled is not None:
         api_key.enabled = data.enabled
@@ -176,15 +183,40 @@ async def update_api_key(
 
     await db.flush()
     await db.refresh(api_key)
-    if data.printer_ids is not None or data.enabled is not None or data.expires_at is not None:
+    if not api_key.enabled or (
+        api_key.expires_at is not None
+        and api_key.expires_at.replace(tzinfo=api_key.expires_at.tzinfo or timezone.utc) < datetime.now(timezone.utc)
+    ):
+        # Its camera and WebSocket tokens go with it; the scope refresh below
+        # closes its open dashboards.
+        await drop_api_key_tokens(db, [api_key.id])
+    if (
+        data.printer_ids is not None
+        or data.enabled is not None
+        or data.expires_at is not None
+        or data.can_read_status is not None
+    ):
         # Sockets opened with this key may now see fewer printers, or none
-        # (#1727). Committed first so the refresh reads the new row.
+        # (#1727), or not connect at all (can_read_status grants
+        # websocket:connect). Committed first so the refresh reads the new row.
         await db.commit()
         await ws_manager.refresh_printer_scopes()
 
     return api_key
 
 
+async def drop_api_key_tokens(db: AsyncSession, key_ids) -> None:
+    """Delete the camera and WebSocket tokens deleted keys minted (media tokens
+    never record the key).
+
+    They name the key by id, which SQLite gives to the next key created: a
+    token of a key limited to one printer would get the new key's printers.
+    """
+    key_ids = list(key_ids)
+    if key_ids:
+        await db.execute(delete(AuthEphemeralToken).where(AuthEphemeralToken.api_key_id.in_(key_ids)))
+
+
 @router.delete("/{key_id}")
 async def delete_api_key(
     key_id: int,
@@ -199,8 +231,11 @@ async def delete_api_key(
         raise HTTPException(status_code=404, detail="API key not found")
 
     await db.delete(api_key)
-    # Sockets opened with this key lose their printers (#1727)
+    await drop_api_key_tokens(db, [key_id])
     await db.commit()
-    await ws_manager.refresh_printer_scopes()
+    # Its sockets are closed, not just given no printers: their scope is looked
+    # up by key id, which SQLite gives to the next key created.
+    await ws_manager.close_api_keys([key_id])
+    forget_app_senders([key_id])
 
     return {"message": "API key deleted"}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 263 - 234
backend/app/api/routes/archives.py


+ 119 - 43
backend/app/api/routes/auth.py

@@ -8,7 +8,7 @@ import jwt as _jwt
 from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status
 from fastapi.security import HTTPAuthorizationCredentials
 from jwt.exceptions import PyJWTError
-from sqlalchemy import delete, select
+from sqlalchemy import delete, or_, select
 from sqlalchemy.exc import SQLAlchemyError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -18,6 +18,7 @@ from backend.app.core.auth import (
     ALGORITHM,
     SECRET_KEY,
     Permission,
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     _is_token_fresh,
     _validate_api_key,
@@ -38,6 +39,7 @@ from backend.app.core.auth import (
     resolve_session_max_minutes,
     revoke_jti,
     security,
+    token_outlived_its_session,
 )
 from backend.app.core.database import async_session, get_db
 from backend.app.core.oidc_env import env_bool
@@ -55,6 +57,7 @@ from backend.app.schemas.auth import (
     GroupBrief,
     LDAPProvisionRequest,
     LDAPSearchResultResponse,
+    LDAPTestRequest,
     LoginRequest,
     LoginResponse,
     ResetPasswordRequest,
@@ -375,7 +378,23 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         # Set auth enabled and mark setup as completed
         await set_auth_enabled(db, request.auth_enabled)
         await set_setup_completed(db, True)
+        if request.auth_enabled:
+            # Tokens minted while it was off name nobody (every tab mints a
+            # WebSocket one): they would reconnect the dashboards closed below.
+            await db.execute(
+                delete(AuthEphemeralToken).where(
+                    or_(AuthEphemeralToken.username.is_(None), AuthEphemeralToken.username == ""),
+                    AuthEphemeralToken.api_key_id.is_(None),
+                    AuthEphemeralToken.token_type != "revoked_jti",
+                )
+            )
         await db.commit()
+        if request.auth_enabled:
+            # Dashboards opened while it was off have every printer and would
+            # keep them. Closed, such a tab re-checks auth and goes to the login.
+            from backend.app.core.websocket import ws_manager
+
+            await ws_manager.close_anonymous()
 
         if admin_created:
             await db.refresh(admin_user)
@@ -666,7 +685,9 @@ async def mint_websocket_token(
     username = current_user.username if current_user is not None else None
     # The socket only carries the printers its minter may see (#1727)
     api_key_id = api_key.id if api_key is not None else None
-    return {"token": await create_websocket_token(username, api_key_id)}
+    # The key's prefix binds the token to this key (SQLite reuses a deleted key's id)
+    api_key_prefix = api_key.key_prefix if api_key is not None else None
+    return {"token": await create_websocket_token(username, api_key_id, api_key_prefix)}
 
 
 @router.post("/media-token")
@@ -829,18 +850,34 @@ async def logout(
 @router.post("/smtp/test", response_model=TestSMTPResponse)
 async def test_smtp_connection(
     test_request: TestSMTPRequest,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Test SMTP connection using saved settings (admin only when auth enabled)."""
+    """Test SMTP connection using the form's settings, else the saved ones (admin only when auth enabled).
+
+    An empty password keeps the saved one, which only goes to the server it
+    was entered for.
+    """
     import logging
 
+    from backend.app.services.secret_fields import SMTP_DESTINATIONS, same_value
+
     logger = logging.getLogger(__name__)
 
     try:
-        smtp_settings = await get_smtp_settings(db)
+        stored = await get_smtp_settings(db)
+        smtp_settings = test_request.smtp or stored
         if not smtp_settings:
             return TestSMTPResponse(success=False, message="SMTP settings not configured. Save SMTP settings first.")
+        if test_request.smtp and smtp_settings.smtp_auth_enabled and not smtp_settings.smtp_password:
+            if stored and stored.smtp_password:
+                moved = [f for f in SMTP_DESTINATIONS if not same_value(getattr(smtp_settings, f), getattr(stored, f))]
+                if moved:
+                    return TestSMTPResponse(
+                        success=False, message=f"Enter the SMTP password again: {', '.join(moved)} changed"
+                    )
+                smtp_settings = smtp_settings.model_copy(update={"smtp_password": stored.smtp_password})
 
         # Send test email
         send_email(
@@ -874,16 +911,40 @@ async def get_smtp_config(
 @router.post("/smtp", response_model=dict)
 async def save_smtp_config(
     smtp_settings: SMTPSettings,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Save SMTP settings (admin only when auth enabled)."""
+    """Save SMTP settings (admin only when auth enabled).
+
+    An empty password keeps the stored one, which only goes to the server it
+    was entered for: a changed host, port or security needs it entered again
+    (without authentication, the stored password is dropped instead).
+    """
     import logging
 
+    from backend.app.services.secret_fields import SMTP_DESTINATIONS, same_value
+
     logger = logging.getLogger(__name__)
 
+    drop_password = False
+    stored = await get_smtp_settings(db)
+    if stored and stored.smtp_password and not smtp_settings.smtp_password:
+        moved = [f for f in SMTP_DESTINATIONS if not same_value(getattr(smtp_settings, f), getattr(stored, f))]
+        if moved and smtp_settings.smtp_auth_enabled:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"Enter the SMTP password again: {', '.join(moved)} changed",
+            )
+        drop_password = bool(moved)
+
     try:
         await save_smtp_settings(db, smtp_settings)
+        if drop_password:
+            from backend.app.core.db_dialect import upsert_setting
+            from backend.app.models.settings import Settings
+
+            await upsert_setting(db, Settings, "smtp_password", "")
         await db.commit()
         logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
         return {"message": "SMTP settings saved successfully"}
@@ -1222,15 +1283,17 @@ async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: Asy
             AuthEphemeralToken.token == request.token,
             AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
         )
-        .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at)
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at, AuthEphemeralToken.created_at)
     )
     row = result.one_or_none()
     await db.commit()
     if row is None:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
 
-    username, expires_at = row
-    # SQLite returns naive datetimes; treat them as UTC.
+    username, expires_at, minted_at = row
+    if await token_outlived_its_session(db, username, minted_at):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
+    # Stored as naive UTC on both databases; treat it as UTC.
     if expires_at.tzinfo is None:
         expires_at = expires_at.replace(tzinfo=timezone.utc)
     if now > expires_at:
@@ -1243,7 +1306,17 @@ async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: Asy
 
     user.password_hash = get_password_hash(request.new_password)
     user.password_changed_at = now  # M-R7-B: invalidate all prior JWTs
+    # The WebSocket and camera tokens too: reusable for an hour, they would
+    # reconnect a closed dashboard straight away.
+    from backend.app.api.routes.users import drop_pending_tokens
+
+    await drop_pending_tokens(db, user.username)
     await db.commit()
+    # Open dashboards are never signed in again: one someone else had open on
+    # the account would keep receiving its printers.
+    from backend.app.core.websocket import ws_manager
+
+    await ws_manager.close_user(user.id)
     _logger.info("Password reset completed for user '%s'", username)
 
     return ForgotPasswordResponse(message="Password has been reset successfully.")
@@ -1357,22 +1430,24 @@ async def reset_user_password(
 # LDAP Authentication Helpers
 
 
+LDAP_SETTING_KEYS = (
+    "ldap_enabled",
+    "ldap_server_url",
+    "ldap_bind_dn",
+    "ldap_bind_password",
+    "ldap_search_base",
+    "ldap_user_filter",
+    "ldap_security",
+    "ldap_group_mapping",
+    "ldap_auto_provision",
+    "ldap_ca_cert_path",
+    "ldap_default_group",
+)
+
+
 async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
     """Get LDAP settings from the database. Returns None if LDAP is not enabled."""
-    ldap_keys = [
-        "ldap_enabled",
-        "ldap_server_url",
-        "ldap_bind_dn",
-        "ldap_bind_password",
-        "ldap_search_base",
-        "ldap_user_filter",
-        "ldap_security",
-        "ldap_group_mapping",
-        "ldap_auto_provision",
-        "ldap_ca_cert_path",
-        "ldap_default_group",
-    ]
-    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+    result = await db.execute(select(Settings).where(Settings.key.in_(LDAP_SETTING_KEYS)))
     settings = {s.key: s.value for s in result.scalars().all()}
     if settings.get("ldap_enabled", "false").lower() != "true":
         return None
@@ -1488,36 +1563,35 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
 
 @router.post("/ldap/test")
 async def test_ldap(
+    body: LDAPTestRequest | None = None,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Test LDAP connection using saved settings (admin only when auth enabled)."""
+    """Test the LDAP connection with the form's settings over the saved ones (admin only when auth enabled).
+
+    Works while LDAP is still off. An empty bind password keeps the saved one,
+    which only goes to the server it was entered for.
+    """
     import logging
 
     from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection
+    from backend.app.services.secret_fields import SETTINGS_SECRETS, SecretConfigError, merge_partial
 
     logger = logging.getLogger(__name__)
 
-    ldap_settings = await _get_ldap_settings(db)
-    if not ldap_settings:
-        # LDAP might not be enabled yet but settings might still exist — read all keys
-        ldap_keys = [
-            "ldap_enabled",
-            "ldap_server_url",
-            "ldap_bind_dn",
-            "ldap_bind_password",
-            "ldap_search_base",
-            "ldap_user_filter",
-            "ldap_security",
-            "ldap_group_mapping",
-            "ldap_auto_provision",
-        ]
-        result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
-        ldap_settings = {s.key: s.value for s in result.scalars().all()}
-        # Force enabled for test
-        ldap_settings["ldap_enabled"] = "true"
+    result = await db.execute(select(Settings).where(Settings.key.in_(LDAP_SETTING_KEYS)))
+    # Unset security is StartTLS, as when saving.
+    stored = {"ldap_security": "starttls", **{s.key: s.value for s in result.scalars().all()}}
+    form = body.model_dump(exclude_none=True) if body else {}
+    if not form.get("ldap_bind_password"):
+        form.pop("ldap_bind_password", None)
+    try:
+        form = merge_partial(stored, form, {"ldap_bind_password": SETTINGS_SECRETS["ldap_bind_password"]})
+    except SecretConfigError as e:
+        return {"success": False, "message": str(e)}
 
-    config = parse_ldap_config(ldap_settings)
+    config = parse_ldap_config({**stored, **form, "ldap_enabled": "true"})
     if not config:
         return {"success": False, "message": "LDAP server URL is not configured"}
 
@@ -1553,6 +1627,7 @@ async def get_ldap_status(db: AsyncSession = Depends(get_db)):
 @router.get("/ldap/search", response_model=list[LDAPSearchResultResponse])
 async def search_ldap_directory(
     q: str,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -1626,6 +1701,7 @@ async def search_ldap_directory(
 @router.post("/ldap/provision", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def provision_ldap_user(
     payload: LDAPProvisionRequest,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -1637,7 +1713,7 @@ async def provision_ldap_user(
     login path (`_provision_ldap_user`), so behavior stays identical regardless
     of whether the user was created here or on first login.
 
-    Requires USERS_CREATE.
+    Admin-only with USERS_CREATE, like users.create_user.
     """
     from sqlalchemy import func as sa_func
 

+ 1 - 1
backend/app/api/routes/bug_report.py

@@ -73,7 +73,7 @@ async def start_logging(
 @router.post("/stop-logging", response_model=StopLoggingResponse)
 async def stop_logging(
     was_debug: bool = Query(default=False),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Collect logs and restore previous log level."""
     logs = await _get_recent_sanitized_logs()

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 521 - 176
backend/app/api/routes/camera.py


+ 143 - 52
backend/app/api/routes/cloud.py

@@ -15,6 +15,7 @@ from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core import database
 from backend.app.core.auth import (
     RequirePermissionIfAuthEnabled,
     _user_from_api_key,
@@ -24,6 +25,7 @@ from backend.app.core.auth import (
 )
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -181,6 +183,22 @@ async def resolve_api_key_cloud_owner(
 router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 
 
+def _forget_account_presets(account: int | None) -> None:
+    """Drop what was read with an account's cloud token, once that token changes."""
+    _filament_id_name_cache.pop(account, None)
+    # A build still running reads with the old token; it must not be joined.
+    _filament_id_map_inflight.pop(account, None)
+    for key in [key for key in _filament_cache if key[0] == account]:
+        del _filament_cache[key]
+
+
+def forget_all_cloud_presets() -> None:
+    """Drop every account's cached presets (the user table was replaced)."""
+    _filament_id_name_cache.clear()
+    _filament_id_map_inflight.clear()
+    _filament_cache.clear()
+
+
 async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
     """Store cloud token, email, and region.
 
@@ -192,6 +210,8 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
     """
     region = _normalise_region(region)
     invalidate_validation_cache(token)
+    # The account may be another one now.
+    _forget_account_presets(user.id if user is not None else None)
     if user is not None:
         # User object is from the auth dependency's session (detached),
         # so use a direct UPDATE via the route's db session.
@@ -228,6 +248,7 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
     token, _email, _region = await get_stored_token(db, user)
     if token:
         invalidate_validation_cache(token)
+    _forget_account_presets(user.id if user is not None else None)
 
     if user is not None:
         await db.execute(
@@ -454,7 +475,8 @@ async def login(
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -500,7 +522,8 @@ async def verify_code(
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -518,16 +541,28 @@ async def set_token(
     selected ``region`` is persisted alongside the token so every subsequent
     request hits the right Bambu API endpoint, including after a restart.
     """
+    # A pasted token often carries a stray space or line break. One that still
+    # cannot be sent as a header (control characters, spaces, non-ASCII) is not
+    # a token Bambu could accept: refused here, without reaching Bambu, and
+    # without echoing it back in an HTTP client's error.
+    token = request.access_token.strip()
+    if not token or not token.isascii() or not token.isprintable() or " " in token:
+        raise HTTPException(status_code=401, detail="Invalid token")
+
     cloud = BambuCloudService(region=request.region)
-    cloud.set_token(request.access_token)
+    cloud.set_token(token)
 
     try:
         # Verify token works by trying to get profile
         await cloud.get_user_profile()
-        await store_token(db, request.access_token, "token-auth", request.region, current_user)
+        await store_token(db, token, "token-auth", request.region, current_user)
         return CloudAuthStatus(is_authenticated=True, email="token-auth")
-    except BambuCloudError:
-        raise HTTPException(status_code=401, detail="Invalid token")
+    except BambuCloudError as e:
+        # Invalid only when Bambu Cloud refused it; a cloud that could not be
+        # reached or failed says nothing about the token.
+        if isinstance(e, BambuCloudAuthError) or e.status_code in (401, 403):
+            raise HTTPException(status_code=401, detail="Invalid token")
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -605,10 +640,12 @@ async def get_slicer_settings(
 
         return result
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -632,10 +669,12 @@ async def get_setting_detail(
         data = await cloud.get_setting_detail(setting_id)
         return data
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -656,8 +695,10 @@ async def get_filament_presets(
     return settings.filament
 
 
-# Cache for filament preset info (setting_id -> {name, k})
-_filament_cache: dict[str, dict] = {}
+# Cache for filament preset info ((account, setting_id) -> {name, k}). The
+# account is the user whose cloud token resolved it, None for the global one:
+# a personal preset read with one account's token is not another account's.
+_filament_cache: dict[tuple[int | None, str], dict] = {}
 _filament_cache_time: float = 0
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 
@@ -668,7 +709,7 @@ FILAMENT_CACHE_TTL = 300  # 5 minutes
 # (a thundering herd against a rate-limited API). The first caller to miss a
 # given id becomes the leader and resolves it; concurrent callers await its
 # future and reuse the result instead of duplicating the call.
-_filament_inflight: dict[str, asyncio.Future] = {}
+_filament_inflight: dict[tuple[int | None, str], asyncio.Future] = {}
 
 
 async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
@@ -714,17 +755,18 @@ async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -
         return None
 
 
-async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
+async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService, account: int | None = None) -> dict | None:
     """Resolve one preset via Bambu Cloud, single-flighting concurrent misses (#2572).
 
     Concurrent callers for the same ``setting_id`` share one cloud round-trip:
     the first caller resolves it while the rest await the shared future. Returns
     the info dict (also populating ``_filament_cache``) or ``None`` on failure.
     """
-    if setting_id in _filament_cache:
-        return _filament_cache[setting_id]
+    key = (account, setting_id)
+    if key in _filament_cache:
+        return _filament_cache[key]
 
-    existing = _filament_inflight.get(setting_id)
+    existing = _filament_inflight.get(key)
     if existing is not None:
         # Another request is already fetching this id — reuse its result.
         # shield() so our own cancellation can't cancel the shared leader.
@@ -734,17 +776,17 @@ async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService) ->
             return None
 
     fut: asyncio.Future = asyncio.get_event_loop().create_future()
-    _filament_inflight[setting_id] = fut
+    _filament_inflight[key] = fut
     info: dict | None = None
     try:
         info = await _fetch_one_cloud_filament(setting_id, cloud)
         return info
     finally:
         if info is not None:
-            _filament_cache[setting_id] = info
+            _filament_cache[key] = info
         if not fut.done():
             fut.set_result(info)
-        _filament_inflight.pop(setting_id, None)
+        _filament_inflight.pop(key, None)
 
 
 # Built-in filament ID → name mapping (fallback when cloud API and local profiles
@@ -843,6 +885,7 @@ async def _enrich_from_local_presets(
     unresolved_ids: list[str],
     result: dict,
     db: AsyncSession,
+    account: int | None = None,
 ) -> dict:
     """Fall back to local profiles for filament IDs not resolved by cloud.
 
@@ -890,7 +933,7 @@ async def _enrich_from_local_presets(
                             info["k"] = k_val
                         except (ValueError, TypeError, IndexError):
                             pass
-                    _filament_cache[original_id] = info
+                    _filament_cache[(account, original_id)] = info
                     result[original_id] = info
             except Exception:
                 continue
@@ -905,13 +948,13 @@ async def _enrich_from_local_presets(
                 # Preserve K value from earlier phases if available
                 existing_k = result.get(fid, {}).get("k")
                 info = {"name": name, "k": existing_k}
-                _filament_cache[fid] = info
+                _filament_cache[(account, fid)] = info
                 result[fid] = info
 
     # Fill remaining unresolved with empty entries
     for fid in unresolved_ids:
         if fid not in result:
-            _filament_cache[fid] = {"name": "", "k": None}
+            _filament_cache[(account, fid)] = {"name": "", "k": None}
             result[fid] = {"name": "", "k": None}
 
     return result
@@ -946,13 +989,14 @@ async def get_filament_info(
 
     result = {}
     unresolved_ids: list[str] = []
+    account = current_user.id if current_user is not None else None
 
     # Phase 1: Check cache
     for setting_id in setting_ids:
         if not setting_id:
             continue
-        if setting_id in _filament_cache:
-            result[setting_id] = _filament_cache[setting_id]
+        if (account, setting_id) in _filament_cache:
+            result[setting_id] = _filament_cache[(account, setting_id)]
         else:
             unresolved_ids.append(setting_id)
 
@@ -971,7 +1015,7 @@ async def get_filament_info(
             try:
                 still_unresolved: list[str] = []
                 for setting_id in unresolved_ids:
-                    info = await _resolve_cloud_filament(setting_id, cloud)
+                    info = await _resolve_cloud_filament(setting_id, cloud, account)
                     if info is not None:
                         result[setting_id] = info
                     if info is None or not info.get("name"):
@@ -984,7 +1028,7 @@ async def get_filament_info(
 
     # Phase 3: Try local profiles for any IDs still without a name
     if unresolved_ids:
-        result = await _enrich_from_local_presets(unresolved_ids, result, db)
+        result = await _enrich_from_local_presets(unresolved_ids, result, db, account)
 
     return result
 
@@ -1018,10 +1062,12 @@ async def get_devices(
             for d in devices
         ]
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1076,6 +1122,8 @@ async def get_firmware_updates(
                         release_notes=firmware_info.get("release_notes"),
                     )
                 )
+            except BambuCloudAuthError:
+                raise  # the session expired: not an unknown version per device
             except BambuCloudError as e:
                 logger.warning("Failed to get firmware info for %s: %s", device_name, e)
                 # Still include device but with unknown firmware status
@@ -1093,10 +1141,12 @@ async def get_firmware_updates(
         return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
 
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1129,10 +1179,12 @@ async def create_setting(
         )
         return data
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1161,10 +1213,12 @@ async def update_setting(
         )
         return data
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1191,10 +1245,12 @@ async def delete_setting(
             message=result.get("message", "Setting deleted"),
         )
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1248,14 +1304,16 @@ async def get_builtin_filaments(
     return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
 
 
-# Cache for filament_id → name mapping (resolved from cloud preset details)
-_filament_id_name_cache: dict[str, str] = {}
-_filament_id_name_cache_time: float = 0
+# filament_id → name mappings (resolved from cloud preset details), one per
+# cloud account: the user whose token read it, or None for the global token
+# used when auth is off. Value: (time read, mapping).
+_filament_id_name_cache: dict[int | None, tuple[float, dict[str, str]]] = {}
+# Builds still running, by account (RC-C11).
+_filament_id_map_inflight: dict[int | None, asyncio.Future] = {}
 
 
 @router.get("/filament-id-map")
 async def get_filament_id_map(
-    db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
     """
@@ -1264,20 +1322,50 @@ async def get_filament_id_map(
     K-profiles store a filament_id (e.g., "P4d64437") which is different from
     the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
     fetches details for all custom presets and returns the mapping.
-    Cached for 5 minutes.
+    Cached for 5 minutes per cloud account; a caller is only ever given the
+    mapping of their own account's presets.
     """
     import time
 
-    global _filament_id_name_cache, _filament_id_name_cache_time
+    account = current_user.id if current_user is not None else None
+    cached = _filament_id_name_cache.get(account)
+    if cached and time.time() - cached[0] < FILAMENT_CACHE_TTL:
+        return cached[1]
 
-    if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
-        return _filament_id_name_cache
+    # One build per account at a time: when the cache expired, every K-profile
+    # view asked at once and each ran the full 1 + N fetch (RC-C11). Registered
+    # before anything is awaited, so callers arriving together all join it.
+    running = _filament_id_map_inflight.get(account)
+    if running is None or running.done():
+        running = spawn_background_task(_build_filament_id_map(current_user, account), name="cloud-filament-id-map")
+        _filament_id_map_inflight[account] = running
 
-    cloud = await build_authenticated_cloud(db, current_user)
+        def _forget(done: asyncio.Future, account: int | None = account) -> None:
+            if _filament_id_map_inflight.get(account) is done:
+                del _filament_id_map_inflight[account]
+
+        running.add_done_callback(_forget)
+    try:
+        # shield: a caller that goes away must not cancel the build others joined.
+        return dict(await asyncio.shield(running))
+    except Exception:
+        return cached[1] if cached else {}
+
+
+async def _build_filament_id_map(user: User | None, account: int | None) -> dict[str, str]:
+    """Fetch an account's custom presets and map their filament ids to names.
+
+    Reads the stored token in a session of its own, closed before the Bambu
+    Cloud round-trips: no request holds a database connection across them.
+    """
+    import time
+
+    async with database.async_session() as db:
+        cloud = await build_authenticated_cloud(db, user)
     if cloud is None or not cloud.is_authenticated:
         if cloud is not None:
             await cloud.close()
-        return _filament_id_name_cache or {}
+        return {}
 
     try:
         data = await cloud.get_slicer_settings()
@@ -1290,7 +1378,10 @@ async def get_filament_id_map(
                 continue
             try:
                 detail = await cloud.get_setting_detail(setting_id)
-                fid = detail.get("filament_id", "")
+                # On the envelope normally; in the preset JSON under `setting`
+                # when not (the same fallback as slicer_filament_resolver).
+                nested = detail.get("setting")
+                fid = detail.get("filament_id") or (nested.get("filament_id") if isinstance(nested, dict) else "")
                 name = detail.get("name", "")
                 if fid and name:
                     # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
@@ -1299,11 +1390,11 @@ async def get_filament_id_map(
             except Exception:
                 pass
 
-        _filament_id_name_cache = result
-        _filament_id_name_cache_time = time.time()
+        # Not when the account signed out meanwhile: its build was dropped,
+        # and this map was read with the token that is gone.
+        if _filament_id_map_inflight.get(account) is asyncio.current_task():
+            _filament_id_name_cache[account] = (time.time(), result)
         return result
-    except Exception:
-        return _filament_id_name_cache or {}
     finally:
         await cloud.close()
 

+ 26 - 18
backend/app/api/routes/connected_apps.py

@@ -38,12 +38,15 @@ from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user,
     get_password_hash,
     get_user_by_username,
     is_auth_enabled,
     security,
+    session_dated,
+    token_outlived_its_session,
     verify_password,
 )
 from backend.app.core.database import get_db
@@ -129,6 +132,7 @@ async def list_connected_apps(
 async def create_connected_app(
     data: ConnectedAppCreate,
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Register an app. The client secret is in this response and nowhere else."""
@@ -157,6 +161,7 @@ async def update_connected_app(
     app_id: int,
     data: ConnectedAppUpdate,
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     app = await _get_app_or_404(db, app_id)
@@ -175,6 +180,7 @@ async def update_connected_app(
 async def rotate_connected_app_secret(
     app_id: int,
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Replace the client secret. The old one stops working immediately."""
@@ -191,6 +197,7 @@ async def rotate_connected_app_secret(
 async def delete_connected_app(
     app_id: int,
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     app = await _get_app_or_404(db, app_id)
@@ -280,33 +287,32 @@ async def authorize(
         db.add(ConnectedAppGrant(app_id=app.id, user_id=user.id))
 
     code = secrets.token_urlsafe(32)
-    db.add(
-        AuthEphemeralToken.new_connect_code(
+    # Read before the commit: a rollback below expires what this session loaded,
+    # and reading it then raises in async SQLAlchemy.
+    username, user_id, app_id, redirect_uri = user.username, user.id, app.id, app.redirect_uri
+
+    def new_code() -> AuthEphemeralToken:
+        return AuthEphemeralToken.new_connect_code(
             code_hash=_hash_code(code),
-            username=user.username,
-            app_id=app.id,
+            username=username,
+            app_id=app_id,
             code_challenge=data.code_challenge,
             expires_at=now + CODE_TTL,
+            # When the session that consents began (core/auth.session_dated)
+            **session_dated(username),
         )
-    )
+
+    db.add(new_code())
     try:
         await db.commit()
     except IntegrityError:
         # Two tabs consenting at once both inserted the grant; the other one won.
         await db.rollback()
-        db.add(
-            AuthEphemeralToken.new_connect_code(
-                code_hash=_hash_code(code),
-                username=user.username,
-                app_id=app.id,
-                code_challenge=data.code_challenge,
-                expires_at=now + CODE_TTL,
-            )
-        )
+        db.add(new_code())
         await db.commit()
 
-    logger.info("Connected app %s: code issued for user %s", app.id, user.id)
-    return ConnectAuthorizeResponse(code=code, redirect_uri=app.redirect_uri)
+    logger.info("Connected app %s: code issued for user %s", app_id, user_id)
+    return ConnectAuthorizeResponse(code=code, redirect_uri=redirect_uri)
 
 
 # --------------------------------------------------------------------------
@@ -370,13 +376,15 @@ async def exchange_code(
             AuthEphemeralToken.provider_id == app.id,
             AuthEphemeralToken.expires_at > now,
         )
-        .returning(AuthEphemeralToken.username, AuthEphemeralToken.nonce)
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.nonce, AuthEphemeralToken.created_at)
     )
     row = consumed.one_or_none()
     await db.commit()
     if row is None:
         raise await fail(400, "invalid_grant", "unknown, expired, reused or foreign code")
-    username, code_challenge = row
+    username, code_challenge, minted_at = row
+    if await token_outlived_its_session(db, username, minted_at):
+        raise await fail(400, "invalid_grant", "code from before the user's current session")
 
     if not hmac.compare_digest(app.redirect_uri, data.redirect_uri):
         raise await fail(400, "invalid_grant", "callback URL mismatch")

+ 1 - 1
backend/app/api/routes/finance.py

@@ -498,7 +498,7 @@ async def get_all_transactions(
     )
 
 
-async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
+async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int | None) -> None:
     """Rebuild through the same canonical ledger repair used at startup."""
     from backend.app.core.database import repair_wallet_ledger_internal
 

+ 3 - 8
backend/app/api/routes/firmware.py

@@ -305,8 +305,7 @@ async def start_firmware_upload(
     1. Downloads the firmware from Bambu Lab
     2. Uploads it to the printer's SD card via FTP
 
-    Progress is broadcast via WebSocket with type "firmware_upload_progress".
-    Use GET /firmware/updates/{printer_id}/upload/status for polling fallback.
+    Poll GET /firmware/updates/{printer_id}/upload/status for progress.
 
     After upload completes, the user must trigger the update from the
     printer's screen (Settings > Firmware).
@@ -339,7 +338,7 @@ async def start_firmware_upload(
 
     return FirmwareUploadStartResponse(
         started=True,
-        message="Firmware upload started. Progress will be broadcast via WebSocket.",
+        message="Firmware upload started. Poll the upload status for progress.",
     )
 
 
@@ -349,11 +348,7 @@ async def get_firmware_upload_status(
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
-    Get the current status of a firmware upload operation.
-
-    This is a polling fallback for clients that don't use WebSocket.
-    For real-time updates, connect to WebSocket and listen for
-    "firmware_upload_progress" messages.
+    Get the current status of a firmware upload operation (poll for progress).
     """
     state = get_upload_state(printer_id)
     return FirmwareUploadStatusResponse(

+ 17 - 7
backend/app/api/routes/github_backup.py

@@ -24,8 +24,8 @@ from backend.app.schemas.github_backup import (
     GitHubRestorePreview,
     GitHubRestoreRequest,
     GitHubRestoreResponse,
+    GitHubTestConnectionRequest,
     GitHubTestConnectionResponse,
-    ProviderType,
     RestoreCategory,
 )
 from backend.app.services.github_backup import github_backup_service
@@ -345,13 +345,19 @@ async def delete_config(
 
 @router.post("/test", response_model=GitHubTestConnectionResponse)
 async def test_connection(
-    repo_url: str = Query(..., description="Repository URL"),
-    token: str = Query(..., description="Personal Access Token"),
-    provider: ProviderType = Query(default=ProviderType.GITHUB, description="Git provider key"),
+    body: GitHubTestConnectionRequest,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Test Git provider connection with provided credentials."""
-    result = await github_backup_service.test_connection(repo_url, token, provider=provider)
+    from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+    # Checked like a saved repository URL (see _enforce_private_repo).
+    try:
+        assert_safe_lan_service_url(body.repo_url, label="Repository URL")
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+    result = await github_backup_service.test_connection(body.repo_url, body.token, provider=body.provider)
     return GitHubTestConnectionResponse(**result)
 
 
@@ -451,7 +457,7 @@ async def list_commits(
 async def preview_restore(
     ref: str = Query(default="HEAD", pattern=REF_PATTERN),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
 ):
     """Report which categories a given backup commit contains."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -460,7 +466,9 @@ async def preview_restore(
     if not config:
         raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
 
-    preview = await github_restore_service.preview(db, config, ref=ref)
+    preview = await github_restore_service.preview(
+        db, config, ref=ref, as_admin=current_user is None or current_user.is_admin
+    )
     return GitHubRestorePreview(**preview)
 
 
@@ -513,6 +521,8 @@ async def restore_backup(
         ref=request.ref,
         categories=request.categories,
         overwrite_existing=request.overwrite_existing,
+        # A non-admin's restore keeps the sign-in and mail settings as they are.
+        as_admin=current_user is None or current_user.is_admin,
     )
     return GitHubRestoreResponse(**restore_result)
 

+ 8 - 8
backend/app/api/routes/groups.py

@@ -331,7 +331,9 @@ async def update_group(
 
     await db.commit()
     await db.refresh(group)
-    if scope_changed:
+    if scope_changed or group_data.permissions is not None:
+        # Printers, or websocket:connect itself (a socket of a user who lost it
+        # is closed)
         await ws_manager.refresh_printer_scopes()
 
     return _group_response(
@@ -361,14 +363,13 @@ async def delete_group(
             detail="Cannot delete system groups",
         )
 
-    restricted = bool(group.restrict_printers)
     # SQLite doesn't enforce the FK cascade
     await db.execute(delete(group_printers).where(group_printers.c.group_id == group_id))
     await db.execute(delete(group_locations).where(group_locations.c.group_id == group_id))
     await db.delete(group)
     await db.commit()
-    if restricted:
-        await ws_manager.refresh_printer_scopes()
+    # Its members may lose printers, or websocket:connect itself
+    await ws_manager.refresh_printer_scopes()
 
 
 @router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -407,8 +408,7 @@ async def add_user_to_group(
 
     group.users.append(user)
     await db.commit()
-    if group.restrict_printers:
-        await ws_manager.refresh_printer_scopes()
+    await ws_manager.refresh_printer_scopes()
 
 
 @router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -447,5 +447,5 @@ async def remove_user_from_group(
 
     group.users.remove(user)
     await db.commit()
-    if group.restrict_printers:
-        await ws_manager.refresh_printer_scopes()
+    # They may lose printers, or websocket:connect itself
+    await ws_manager.refresh_printer_scopes()

+ 25 - 11
backend/app/api/routes/ha_sensors.py

@@ -6,9 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, RequirePrinterPermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer_ha_sensor import PrinterHASensor
 from backend.app.models.user import User
@@ -49,14 +54,25 @@ async def _refresh_quietly(sensor: PrinterHASensor, db: AsyncSession) -> None:
         logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
 
 
+async def _load_sensor(db: AsyncSession, sensor_id: int, printer_scope: PrinterScope) -> PrinterHASensor:
+    """The sensor, if its printer is one the caller may use; otherwise the 404 of a missing one."""
+    sensor = await db.get(PrinterHASensor, sensor_id)
+    if not sensor or not printer_scope.allows(sensor.printer_id):
+        raise HTTPException(404, "Sensor not found")
+    return sensor
+
+
 @router.get("/", response_model=list[PrinterHASensorResponse])
 async def list_ha_sensors(
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = _READ,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List configured sensors, grouped by printer and in display order."""
     query = select(PrinterHASensor)
+    if (scope_clause := printer_scope.where(PrinterHASensor.printer_id)) is not None:
+        query = query.where(scope_clause)
     if printer_id is not None:
         query = query.where(PrinterHASensor.printer_id == printer_id)
     result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order))
@@ -133,8 +149,10 @@ async def create_ha_sensor(
     data: PrinterHASensorCreate,
     db: AsyncSession = Depends(get_db),
     _: User | None = _CREATE,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Bind a Home Assistant entity to a printer."""
+    printer_scope.ensure(data.printer_id)
     printer = await db.get(Printer, data.printer_id)
     if not printer:
         raise HTTPException(404, "Printer not found")
@@ -167,11 +185,9 @@ async def get_ha_sensor(
     sensor_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = _READ,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    sensor = await db.get(PrinterHASensor, sensor_id)
-    if not sensor:
-        raise HTTPException(404, "Sensor not found")
-    return sensor
+    return await _load_sensor(db, sensor_id, printer_scope)
 
 
 @router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
@@ -180,10 +196,9 @@ async def update_ha_sensor(
     data: PrinterHASensorUpdate,
     db: AsyncSession = Depends(get_db),
     _: User | None = _UPDATE,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    sensor = await db.get(PrinterHASensor, sensor_id)
-    if not sensor:
-        raise HTTPException(404, "Sensor not found")
+    sensor = await _load_sensor(db, sensor_id, printer_scope)
 
     updates = data.model_dump(exclude_unset=True)
 
@@ -226,10 +241,9 @@ async def delete_ha_sensor(
     sensor_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = _DELETE,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    sensor = await db.get(PrinterHASensor, sensor_id)
-    if not sensor:
-        raise HTTPException(404, "Sensor not found")
+    sensor = await _load_sensor(db, sensor_id, printer_scope)
 
     name = sensor.name
     await db.delete(sensor)

+ 170 - 70
backend/app/api/routes/inventory.py

@@ -1,6 +1,6 @@
 import json
 import logging
-from datetime import date, datetime, time, timezone
+from datetime import date, datetime, timezone
 
 import httpx
 from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
@@ -35,6 +35,7 @@ from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier, supplier_name_key
 from backend.app.models.user import User
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.spool import (
     MaterialNumberStats,
     SpoolAssignmentCreate,
@@ -60,6 +61,7 @@ from backend.app.schemas.supplier import (
     SupplierUpdate,
 )
 from backend.app.services import slot_unlink_grace
+from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.location_service import (
     DUPLICATE_LOCATION_NAME,
@@ -85,6 +87,7 @@ from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 from backend.app.services.supplier_links import apply_supplier_inheritance, apply_supplier_inheritance_to_batch
 from backend.app.services.tag_conflict import tag_already_linked
+from backend.app.utils.filament_finish import different_finish, finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_IDS_REPLACED_ON_REUSE,
@@ -92,6 +95,7 @@ from backend.app.utils.filament_ids import (
     normalize_slicer_filament,
 )
 from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
+from backend.app.utils.local_time import day_zone, local_date_end, local_date_start
 from backend.app.utils.natural_sort import natural_sort_key
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
@@ -132,6 +136,7 @@ async def apply_spool_to_slot_via_mqtt(
     tray_id: int,
     current_tray_info_idx: str = "",
     current_tray_type: str = "",
+    current_tray_sub_brands: str = "",
 ) -> bool:
     """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
 
@@ -222,6 +227,9 @@ async def apply_spool_to_slot_via_mqtt(
             and not is_material_name(current_tray_info_idx)
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
+            # The same type in another finish is another preset: a Silk spool
+            # does not take the slot's Matte id. Only when both name a finish.
+            and not different_finish(f"{spool.material or ''} {spool.subtype or ''}", current_tray_sub_brands)
         ):
             tray_info_idx = current_tray_info_idx
         elif tray_type:
@@ -231,7 +239,8 @@ async def apply_spool_to_slot_via_mqtt(
             # (GFG96) that reducing it to "PETG" would trade away for GFG99.
             material = (spool.material or "").upper().strip()
             generic = (
-                GENERIC_FILAMENT_IDS.get(material)
+                finish_generic_id(tray_type, f"{spool.material or ''} {spool.subtype or ''}")
+                or GENERIC_FILAMENT_IDS.get(material)
                 or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
                 or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                 or ""
@@ -423,7 +432,8 @@ class CatalogEntryCreate(BaseModel):
     weight: int
 
 
-class CatalogEntryUpdate(BaseModel):
+class CatalogEntryUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "spool_catalog"
     name: str
     weight: int
 
@@ -471,7 +481,8 @@ class ColorEntryCreate(BaseModel):
         return normalize_effect_type(v)
 
 
-class ColorEntryUpdate(BaseModel):
+class ColorEntryUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "color_catalog"
     manufacturer: str
     color_name: str
     hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
@@ -631,14 +642,24 @@ async def _spool_counts_for_locations(
     db: AsyncSession,
     locations: list[Location],
     settings: dict[str, str],
+    *,
+    strict: bool = False,
 ) -> dict[int, int]:
+    """Spools at each location. In Spoolman mode they are Spoolman's; when it
+    can't be read the built-in counts are shown instead, which ``strict``
+    refuses with a 503 (SM-C13): the built-in table holds no Spoolman spools,
+    so a location still in use would count as empty and could be deleted."""
     if _spoolman_is_enabled(settings):
         client = await _ensure_spoolman_client(settings)
+        if not client and strict:
+            raise HTTPException(status_code=503, detail="Spoolman is unavailable")
         if client:
             try:
                 spools = await client.get_all_spools(allow_archived=False)
-            except Exception:
+            except Exception as exc:
                 logger.warning("Failed to fetch Spoolman spools for location counts", exc_info=True)
+                if strict:
+                    raise HTTPException(status_code=503, detail="Spoolman is unavailable") from exc
             else:
                 # Use the canonical key helper so this matches what the
                 # migration backfill, Location.name_key, and every other
@@ -781,7 +802,7 @@ async def delete_location(
         raise HTTPException(status_code=404, detail="Location not found")
 
     settings = await _load_settings_map(db)
-    counts = await _spool_counts_for_locations(db, [location], settings)
+    counts = await _spool_counts_for_locations(db, [location], settings, strict=True)
     if counts.get(location.id, 0) > 0:
         raise HTTPException(status_code=409, detail="Location has spools assigned and cannot be deleted")
 
@@ -1407,7 +1428,6 @@ async def export_spools_csv(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Export the active inventory as CSV (same schema the importer accepts)."""
-    from datetime import datetime, timezone
 
     query = (
         select(Spool)
@@ -1477,7 +1497,8 @@ async def import_spools_csv(
             # the other create paths: the file is authoritative. A CSV that
             # leaves the column blank is stating "no number", not asking for
             # one to be guessed from whatever else is in the inventory.
-            spool = Spool(**row.spool)
+            # The file has no lock column: a used weight in it is locked.
+            spool = Spool(**_lock_entered_weight(dict(row.spool), set()))
             db.add(spool)
             # Supplier assignments resolved by name during parsing (#2988).
             # Flush first so the spool has an id to hang the links on.
@@ -1562,6 +1583,15 @@ async def get_spool(
     return spool
 
 
+def _lock_entered_weight(payload: dict, fields_set: set[str]) -> dict:
+    """A used weight entered for a new spool is locked, as an edited one is:
+    otherwise the AMS remain% sync can overwrite it on its first pass. An
+    explicit ``weight_locked`` from the client wins."""
+    if (payload.get("weight_used") or 0) > 0 and "weight_locked" not in fields_set:
+        payload["weight_locked"] = True
+    return payload
+
+
 @router.post("/spools", response_model=SpoolResponse)
 async def create_spool(
     spool_data: SpoolCreate,
@@ -1573,6 +1603,7 @@ async def create_spool(
         payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
     except ValueError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from exc
+    payload = _lock_entered_weight(payload, set(spool_data.model_fields_set))
     # A new spool of an already-numbered product inherits its material number (#2870).
     payload = await apply_material_number_inheritance(db, payload)
     spool = Spool(**payload)
@@ -1601,6 +1632,7 @@ async def bulk_create_spools(
         payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
     except ValueError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from exc
+    payload = _lock_entered_weight(payload, fields_set)
     # A new spool of an already-numbered product inherits its material number (#2870).
     payload = await apply_material_number_inheritance(db, payload)
     for _ in range(data.quantity):
@@ -1665,12 +1697,40 @@ async def delete_spool(
     if not spool:
         raise HTTPException(404, "Spool not found")
 
+    freed = await _release_slots(db, [spool.id])
     await db.delete(spool)
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"status": "deleted"}
 
 
+async def _release_slots(db: AsyncSession, spool_ids: list[int]) -> list[tuple[int, int, int]]:
+    """Unassign archived or deleted spools from their AMS slots; returns the
+    freed slots.
+
+    Assigning refuses an archived spool and nothing downstream checks the flag,
+    so a slot left pointing at one would keep showing it and charge it the next
+    print's usage. A deleted spool's slots go with it, but other tabs refresh
+    their slot views only when the slots are announced. The caller commits,
+    then announces the slots.
+    """
+    if not spool_ids:
+        return []
+    rows = (await db.execute(select(SpoolAssignment).where(SpoolAssignment.spool_id.in_(spool_ids)))).scalars().all()
+    freed = [(a.printer_id, a.ams_id, a.tray_id) for a in rows]
+    for assignment in rows:
+        await db.delete(assignment)
+    return freed
+
+
+async def _announce_freed_slots(freed: list[tuple[int, int, int]]) -> None:
+    for printer_id, ams_id, tray_id in freed:
+        await ws_manager.broadcast(
+            {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
+        )
+
+
 @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
 async def archive_spool(
     spool_id: int,
@@ -1678,7 +1738,6 @@ async def archive_spool(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Soft-delete a spool by setting archived_at."""
-    from datetime import datetime, timezone
 
     result = await db.execute(select(Spool).where(Spool.id == spool_id))
     spool = result.scalar_one_or_none()
@@ -1686,9 +1745,11 @@ async def archive_spool(
         raise HTTPException(404, "Spool not found")
 
     spool.archived_at = datetime.now(timezone.utc)
+    freed = await _release_slots(db, [spool.id])
     await db.commit()
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return result.scalar_one()
 
 
@@ -1768,7 +1829,8 @@ async def bulk_reset_spool_consumed_counter(
         spool.weight_used_baseline = spool.weight_used or 0
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
-    return {"reset": len(spools)}
+    found = {spool.id for spool in spools}
+    return {"reset": len(spools), "not_found": [sid for sid in spool_ids if sid not in found]}
 
 
 class BulkUpdateRequest(BaseModel):
@@ -1835,11 +1897,13 @@ async def bulk_delete_spools(
     spools = list(result.scalars().all())
     found_ids = {s.id for s in spools}
     not_found = [sid for sid in payload.ids if sid not in found_ids]
+    freed = await _release_slots(db, list(found_ids))
     for spool in spools:
         await db.delete(spool)
     await db.commit()
     if spools:
         await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"deleted": len(spools), "not_found": not_found}
 
 
@@ -1850,7 +1914,6 @@ async def bulk_archive_spools(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
-    from datetime import datetime, timezone
 
     result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
     spools = list(result.scalars().all())
@@ -1865,9 +1928,12 @@ async def bulk_archive_spools(
             continue
         spool.archived_at = now
         archived.append(spool.id)
+    # Already-archived ones too: archived before archiving released slots.
+    freed = await _release_slots(db, archived + already)
     await db.commit()
     if archived:
         await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"archived": len(archived), "already_archived": already, "not_found": not_found}
 
 
@@ -2159,6 +2225,7 @@ async def assign_spool(
     fingerprint_color = None
     fingerprint_type = None
     current_tray_info_idx = ""
+    current_tray_sub_brands = ""
     tray_state: int | None = None
     # Firmware's tray_exist_bits answer for this slot, when the payload carries
     # one. Outranks tray_state below — see services/ams_slot_presence.py.
@@ -2174,6 +2241,7 @@ async def assign_spool(
                     fingerprint_color = vt.get("tray_color", "")
                     fingerprint_type = vt.get("tray_type", "")
                     current_tray_info_idx = vt.get("tray_info_idx", "")
+                    current_tray_sub_brands = vt.get("tray_sub_brands", "")
                     raw_state = vt.get("state")
                     if isinstance(raw_state, int):
                         tray_state = raw_state
@@ -2197,34 +2265,37 @@ async def assign_spool(
                 fingerprint_color = tray.get("tray_color", "")
                 fingerprint_type = tray.get("tray_type", "")
                 current_tray_info_idx = tray.get("tray_info_idx", "")
+                current_tray_sub_brands = tray.get("tray_sub_brands", "")
                 raw_state = tray.get("state")
                 if isinstance(raw_state, int):
                     tray_state = raw_state
                 tray_has_spool = spool_present(tray)
 
-    # 3. Upsert assignment (replace if same printer+ams+tray)
-    existing = await db.execute(
-        select(SpoolAssignment).where(
-            SpoolAssignment.printer_id == data.printer_id,
-            SpoolAssignment.ams_id == data.ams_id,
-            SpoolAssignment.tray_id == data.tray_id,
+    # 3. Upsert assignment (replace if same printer+ams+tray), under the lock
+    # the AMS change handler writes the printer's slots with.
+    async with get_ams_assignment_lock(data.printer_id):
+        existing = await db.execute(
+            select(SpoolAssignment).where(
+                SpoolAssignment.printer_id == data.printer_id,
+                SpoolAssignment.ams_id == data.ams_id,
+                SpoolAssignment.tray_id == data.tray_id,
+            )
         )
-    )
-    old = existing.scalar_one_or_none()
-    if old:
-        await db.delete(old)
-        await db.flush()
-
-    assignment = SpoolAssignment(
-        spool_id=data.spool_id,
-        printer_id=data.printer_id,
-        ams_id=data.ams_id,
-        tray_id=data.tray_id,
-        fingerprint_color=fingerprint_color,
-        fingerprint_type=fingerprint_type,
-    )
-    db.add(assignment)
-    await db.commit()
+        old = existing.scalar_one_or_none()
+        if old:
+            await db.delete(old)
+            await db.flush()
+
+        assignment = SpoolAssignment(
+            spool_id=data.spool_id,
+            printer_id=data.printer_id,
+            ams_id=data.ams_id,
+            tray_id=data.tray_id,
+            fingerprint_color=fingerprint_color,
+            fingerprint_type=fingerprint_type,
+        )
+        db.add(assignment)
+        await db.commit()
     await db.refresh(assignment)
     slot_unlink_grace.forget_slot(data.printer_id, data.ams_id, data.tray_id)
 
@@ -2297,6 +2368,7 @@ async def assign_spool(
                 tray_id=data.tray_id,
                 current_tray_info_idx=current_tray_info_idx,
                 current_tray_type=fingerprint_type or "",
+                current_tray_sub_brands=current_tray_sub_brands,
             )
         except Exception as e:
             logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
@@ -2362,19 +2434,20 @@ async def unassign_spool(
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Unassign a spool from an AMS slot."""
-    result = await db.execute(
-        select(SpoolAssignment).where(
-            SpoolAssignment.printer_id == printer_id,
-            SpoolAssignment.ams_id == ams_id,
-            SpoolAssignment.tray_id == tray_id,
+    async with get_ams_assignment_lock(printer_id):
+        result = await db.execute(
+            select(SpoolAssignment).where(
+                SpoolAssignment.printer_id == printer_id,
+                SpoolAssignment.ams_id == ams_id,
+                SpoolAssignment.tray_id == tray_id,
+            )
         )
-    )
-    assignment = result.scalar_one_or_none()
-    if not assignment:
-        raise HTTPException(404, "Assignment not found")
+        assignment = result.scalar_one_or_none()
+        if not assignment:
+            raise HTTPException(404, "Assignment not found")
 
-    await db.delete(assignment)
-    await db.commit()
+        await db.delete(assignment)
+        await db.commit()
 
     await ws_manager.broadcast(
         {
@@ -2543,6 +2616,9 @@ async def get_spool_usage_history(
 async def get_supplier_stats(
     date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
     date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
@@ -2563,9 +2639,9 @@ async def get_supplier_stats(
     purchase_link = (SpoolSupplier.spool_id == Spool.id) & SpoolSupplier.is_purchase_source.is_(True)
     usage_window = []
     if date_from:
-        usage_window.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc))
+        usage_window.append(SpoolUsageHistory.created_at >= local_date_start(date_from, day_zone(tz)))
     if date_to:
-        usage_window.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc))
+        usage_window.append(SpoolUsageHistory.created_at <= local_date_end(date_to, day_zone(tz)))
 
     remaining = Spool.label_weight - Spool.weight_used
     inventory_rows = await db.execute(
@@ -2631,6 +2707,9 @@ async def get_supplier_stats(
 async def get_material_number_stats(
     date_from: date | None = Query(None),
     date_to: date | None = Query(None),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
@@ -2655,11 +2734,13 @@ async def get_material_number_stats(
     # validator, so NULL is the only "unset" state to exclude here.
     has_number = Spool.material_number.is_not(None)
 
-    usage_filters = [has_number]
+    from sqlalchemy import ColumnElement
+
+    usage_filters: list[ColumnElement[bool]] = [has_number]
     if date_from:
-        usage_filters.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc))
+        usage_filters.append(SpoolUsageHistory.created_at >= local_date_start(date_from, day_zone(tz)))
     if date_to:
-        usage_filters.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc))
+        usage_filters.append(SpoolUsageHistory.created_at <= local_date_end(date_to, day_zone(tz)))
 
     # Clamped PER SPOOL, like every other remaining-weight computation in the
     # codebase: a spool whose weight_used overshot its label_weight holds 0 g,
@@ -2757,9 +2838,14 @@ async def sync_weights_from_ams(
 
     Overwrites the database weight_used for every assigned spool using the
     current AMS remain% from connected printers.  This is a manual recovery
-    tool — it bypasses the normal "only increase" guard.
+    tool — it bypasses the normal "only increase" guard, and the weight lock
+    a hand-set or weighed figure carries, which it releases.
+
+    A printer that is printing is left alone: its print's usage is charged
+    when it completes, on top of whatever weight the spool has by then.
     """
-    from backend.app.services.printer_manager import printer_manager
+    from backend.app.services.printer_manager import PrinterManager, printer_manager
+    from backend.app.services.usage_tracker import printer_is_charging
 
     result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
     # Only slots on printers the caller may see (#1727)
@@ -2768,6 +2854,7 @@ async def sync_weights_from_ams(
 
     synced = 0
     skipped = 0
+    printing = 0
 
     for assignment in assignments:
         spool = assignment.spool
@@ -2776,13 +2863,9 @@ async def sync_weights_from_ams(
             skipped += 1
             continue
 
-        if spool.weight_locked:
-            logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
-            skipped += 1
-            continue
-
         state = printer_manager.get_status(assignment.printer_id)
-        if not state or not state.raw_data:
+        # A disconnected printer keeps its last report, which is not a reading.
+        if not state or not state.raw_data or getattr(state, "connected", True) is False:
             logger.info(
                 "AMS weight sync: printer %d not connected, skipping spool %d",
                 assignment.printer_id,
@@ -2791,6 +2874,15 @@ async def sync_weights_from_ams(
             skipped += 1
             continue
 
+        # Until completion has charged the print the printer counts as printing,
+        # also after it has reported the print finished.
+        printer_state = str(getattr(state, "state", "") or "").upper()
+        if printer_state in PrinterManager.ACTIVE_PRINT_STATES or printer_is_charging(assignment.printer_id):
+            logger.info("AMS weight sync: printer %d is printing, skipping spool %d", assignment.printer_id, spool.id)
+            skipped += 1
+            printing += 1
+            continue
+
         ams_raw = state.raw_data.get("ams", [])
         if isinstance(ams_raw, dict):
             ams_raw = ams_raw.get("ams", [])
@@ -2836,12 +2928,18 @@ async def sync_weights_from_ams(
                 remain_val,
             )
             spool.weight_used = new_used
+            spool.weight_locked = False
             synced += 1
         else:
+            if spool.weight_locked:
+                # The figure already agrees with the AMS; the override still applies.
+                spool.weight_locked = False
             skipped += 1
 
     await db.commit()
-    return {"synced": synced, "skipped": skipped}
+    if synced:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"synced": synced, "skipped": skipped, "printing": printing}
 
 
 # ── Helpers ──────────────────────────────────────────────────────────────────
@@ -3050,7 +3148,6 @@ async def update_shopping_list_status(
     ),
 ):
     """Update the purchase status of a shopping list item."""
-    from datetime import datetime, timezone
 
     from backend.app.models.shopping_list import ShoppingListItem
 
@@ -3181,17 +3278,20 @@ async def create_spool_from_slot(
     if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
         raise HTTPException(status_code=400, detail="Slot has no RFID tag")
 
-    spool = await create_spool_from_tray(db, tray)
-    await auto_assign_spool(
-        req.printer_id,
-        req.ams_id,
-        req.tray_id,
-        spool,
-        printer_manager,
-        db,
-        tray_info_idx=tray.get("tray_info_idx", ""),
-    )
-    await db.commit()
+    # Under the slot lock from the first write: on SQLite the spool insert takes
+    # the write lock, which an AMS update holding the slot lock would wait for.
+    async with get_ams_assignment_lock(req.printer_id):
+        spool = await create_spool_from_tray(db, tray)
+        await auto_assign_spool(
+            req.printer_id,
+            req.ams_id,
+            req.tray_id,
+            spool,
+            printer_manager,
+            db,
+            tray_info_idx=tray.get("tray_info_idx", ""),
+        )
+        await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
     await ws_manager.broadcast(
         {

+ 46 - 4
backend/app/api/routes/kprofiles.py

@@ -21,6 +21,7 @@ from backend.app.schemas.kprofile import (
     KProfileNoteResponse,
     KProfilesResponse,
 )
+from backend.app.services.bambu_mqtt import CALI_ACK_UNCONFIRMED
 from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
@@ -120,6 +121,8 @@ async def set_kprofile(
     from backend.app.utils.printer_models import is_dual_nozzle_model
 
     is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
+    # Whether the printer answered the delete of a delete-and-add edit.
+    delete_confirmed = True
 
     if is_edit and is_dual_nozzle:
         # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
@@ -151,6 +154,7 @@ async def set_kprofile(
         ok, detail = await client.await_cali_ack(delete_success)
         if not ok:
             raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
+        delete_confirmed = detail != CALI_ACK_UNCONFIRMED
 
         # Wait for printer to process the delete before adding
         await asyncio.sleep(0.5)
@@ -188,9 +192,43 @@ async def set_kprofile(
     ok, detail = await client.await_cali_ack(success)
     if not ok:
         raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
+    confirmed = detail != CALI_ACK_UNCONFIRMED
 
-    message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
-    return {"success": True, "message": message}
+    if is_edit and not is_dual_nozzle:
+        # The edit deleted the profile and added it again: read the table back
+        # to see the edited one landed. An empty table is also what a read that
+        # timed out returns, so only a table without it is proof. Same pause as
+        # before the add.
+        await asyncio.sleep(0.5)
+        table = await client.get_kprofiles(nozzle_diameter=profile.nozzle_diameter)
+        if table:
+            if not any(_is_saved_profile(stored, profile) for stored in table):
+                raise HTTPException(
+                    502,
+                    "The edited K-profile is not in the printer's table. Check the list before adding it again.",
+                )
+            # The old one may still be there when its delete went unanswered.
+            confirmed = delete_confirmed
+
+    if not confirmed:
+        message = "K-profile sent; the printer did not confirm it"
+    else:
+        message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
+    return {"success": True, "confirmed": confirmed, "message": message}
+
+
+def _is_saved_profile(stored, profile: KProfileCreate) -> bool:
+    """Whether a profile read from the printer is the one just written."""
+    try:
+        same_k = abs(float(stored.k_value) - float(profile.k_value)) < 1e-6
+    except (TypeError, ValueError):
+        return False
+    return (
+        same_k
+        and stored.name == profile.name
+        and stored.filament_id == profile.filament_id
+        and stored.extruder_id == profile.extruder_id
+    )
 
 
 @router.post("/batch", response_model=dict)
@@ -253,7 +291,11 @@ async def set_kprofiles_batch(
     if not ok:
         raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
 
-    return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
+    return {
+        "success": True,
+        "confirmed": detail != CALI_ACK_UNCONFIRMED,
+        "message": f"Added {len(profiles)} K-profiles",
+    }
 
 
 @router.delete("/", response_model=dict)
@@ -304,7 +346,7 @@ async def delete_kprofile(
     # Wait for printer to process the delete before frontend refetches
     await asyncio.sleep(0.5)
 
-    return {"success": True, "message": "K-profile deleted successfully"}
+    return {"success": True, "confirmed": detail != CALI_ACK_UNCONFIRMED, "message": "K-profile deleted successfully"}
 
 
 @router.get("/notes", response_model=KProfileNoteResponse)

+ 4 - 5
backend/app/api/routes/labels.py

@@ -268,10 +268,9 @@ async def _local_label_data(spool_ids: list[int], request: Request, db: AsyncSes
 
 
 async def _spoolman_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
-    """The Spoolman client doesn't expose a per-id endpoint, so this fetches the
-    full spool list and filters in-memory. For typical libraries (~50 spools)
-    that's negligible; for very large libraries this is the trade-off until
-    Spoolman gains a bulk filter.
+    """Fetches the full spool list once and filters in-memory, rather than one
+    request per id. Archived spools included: Spoolman leaves them out of its
+    plain list, and the Inventory page can show and select them (SM-C12).
     """
     spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
     if not spoolman_on:
@@ -282,7 +281,7 @@ async def _spoolman_label_data(spool_ids: list[int], request: Request, db: Async
         raise HTTPException(503, "Spoolman not reachable")
 
     try:
-        all_spools = await client.get_spools()
+        all_spools = await client.get_all_spools(allow_archived=True)
     except Exception as exc:
         logger.warning("Spoolman fetch failed during label render: %s", exc)
         raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc

+ 189 - 103
backend/app/api/routes/library.py

@@ -12,23 +12,25 @@ import re
 import shutil
 import uuid
 import zipfile
+from concurrent.futures import ThreadPoolExecutor
 from datetime import datetime, timezone
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
-from sqlalchemy import distinct, func, select, update
+from sqlalchemy import delete, distinct, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
-from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
+from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf, printed_plate_of_multi_plate_file
 from backend.app.core.auth import (
     ApiKeyActor,
     QueueReviewRequired,
     RequestActor,
     RequestPrinterScope,
+    require_actor_still_exists,
     require_media_token_ownership,
     require_ownership_permission,
     require_permission_if_auth_enabled,
@@ -74,7 +76,7 @@ from backend.app.schemas.library import (
     ZipExtractResult,
 )
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
-from backend.app.services.archive import ThreeMFParser
+from backend.app.services.archive import ThreeMFParser, estimate_filament_cost
 from backend.app.services.design_settings import (
     DesignOverride,
     apply_design_overrides,
@@ -96,7 +98,9 @@ from backend.app.services.library_folder_access import (
 from backend.app.services.pdf_thumbnail import generate_pdf_thumbnail
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
+from backend.app.services.print_defaults import default_print_options
 from backend.app.services.process_overrides import apply_process_overrides
+from backend.app.services.queue_announcements import announce_queue_jobs_added
 from backend.app.services.slice_output_check import (
     missing_start_gcode_message,
     start_gcode_is_missing,
@@ -135,7 +139,7 @@ _PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
 
 def _ensure_library_file_visible(
     library_file: LibraryFile | None,
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_read_all: bool,
 ) -> LibraryFile:
     """Per-file visibility gate for ownership-scoped LIBRARY reads (#1726-adjacent).
@@ -159,7 +163,28 @@ def _ensure_library_file_visible(
     return library_file
 
 
-def _own_files_filter(user: User | None) -> list:
+async def _linkable_archives(db: AsyncSession, archive_ids, printer_scope: PrinterScope) -> dict[int, str | None]:
+    """Print names of the archives among ``archive_ids`` on the caller's printers.
+
+    A folder's link to any other archive reads as no link, and linking one
+    answers as for a missing archive.
+    """
+    ids = {i for i in archive_ids if i}
+    if not ids:
+        return {}
+    query = select(PrintArchive.id, PrintArchive.print_name).where(PrintArchive.id.in_(ids))
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        query = query.where(clause)
+    return dict((await db.execute(query)).all())
+
+
+def _archive_link(archive_id: int | None, archives: dict[int, str | None]) -> dict:
+    if archive_id not in archives:
+        return {"archive_id": None, "archive_name": None}
+    return {"archive_id": archive_id, "archive_name": archives[archive_id]}
+
+
+def _own_files_filter(user: User | ApiKeyActor | None) -> list:
     """Extra WHERE terms that count only ``user``'s files, for a read_own user.
 
     Folder file counts and activity times must not reveal files the user
@@ -170,7 +195,7 @@ def _own_files_filter(user: User | None) -> list:
     return [LibraryFile.created_by_id == user.id]
 
 
-async def _load_index(db: AsyncSession, user: User | None) -> FolderIndex:
+async def _load_index(db: AsyncSession, user: User | ApiKeyActor | None) -> FolderIndex:
     """The folder index a response for ``user`` needs (#3201).
 
     Who owns which file is needed only to decide what a read_own user sees
@@ -183,7 +208,7 @@ async def _load_index(db: AsyncSession, user: User | None) -> FolderIndex:
     return await load_folder_index(db, with_files=needs_files)
 
 
-def _folder_access_fields(index: FolderIndex, folder: LibraryFolder, user: User | None) -> dict:
+def _folder_access_fields(index: FolderIndex, folder: LibraryFolder, user: User | ApiKeyActor | None) -> dict:
     """The ownership fields of a folder response, for the user asking (#3201)."""
     return {
         "created_by_id": folder.created_by_id,
@@ -734,9 +759,7 @@ async def save_3mf_bytes_to_library(
         # a triangle.
         try:
             if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
-                thumbnail_path = await asyncio.to_thread(
-                    generate_stl_thumbnail, file_path, get_library_thumbnails_dir()
-                )
+                thumbnail_path = await _render_stl_thumbnail(file_path, get_library_thumbnails_dir())
         except Exception as exc:  # noqa: BLE001 — a thumbnail must never fail the import
             logger.warning("Failed to render STL thumbnail for %s: %s", filename, exc)
 
@@ -911,6 +934,19 @@ MAX_CLIENT_THUMBNAIL_EDGE = 2048
 STORED_CLIENT_THUMBNAIL_EDGE = 512
 
 
+# STL thumbnails get their own single worker thread. They are made one at a
+# time anyway (stl_thumbnail._RENDER_LOCK), and in the shared default pool the
+# ones waiting their turn would hold threads that every other to_thread call in
+# the app needs: a burst of STL uploads would stall them all.
+_STL_THUMBNAIL_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="stl-thumbnail")
+
+
+async def _render_stl_thumbnail(stl_path: Path, thumbnails_dir: Path) -> str | None:
+    """generate_stl_thumbnail, off the event loop: a large mesh takes seconds."""
+    loop = asyncio.get_running_loop()
+    return await loop.run_in_executor(_STL_THUMBNAIL_EXECUTOR, generate_stl_thumbnail, stl_path, thumbnails_dir)
+
+
 async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
     """Generate STL and PDF thumbnails for an external folder tree in the background.
 
@@ -969,7 +1005,7 @@ async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
             except OSError:
                 continue
             try:
-                thumb_path = generate_stl_thumbnail(abs_path, thumbnails_dir)
+                thumb_path = await _render_stl_thumbnail(abs_path, thumbnails_dir)
             except Exception as exc:  # noqa: BLE001 — never let one bad STL kill the rest
                 logger.debug("STL thumbnail backfill skipped %s: %s", abs_path, exc)
                 continue
@@ -986,12 +1022,13 @@ async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
 async def list_folders(
     response: Response,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get all folders as a tree structure.
 
@@ -1007,12 +1044,12 @@ async def list_folders(
 
     # Get all folders with project and archive joins
     result = await db.execute(
-        select(LibraryFolder, Project.name, PrintArchive.print_name)
+        select(LibraryFolder, Project.name)
         .outerjoin(Project, LibraryFolder.project_id == Project.id)
-        .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
         .order_by(LibraryFolder.name)
     )
     rows = [row for row in result.all() if visible is None or row[0].id in visible]
+    archives = await _linkable_archives(db, [row[0].archive_id for row in rows], printer_scope)
     own_files = _own_files_filter(user)
 
     # Get file counts per folder
@@ -1046,7 +1083,7 @@ async def list_folders(
     folder_map = {}
     root_folders = []
 
-    for folder, project_name, archive_name in rows:
+    for folder, project_name in rows:
         own_activity = folder.fs_modified_at or folder.updated_at
         latest_file = latest_file_activity.get(folder.id)
         if latest_file is not None and latest_file > own_activity:
@@ -1056,9 +1093,8 @@ async def list_folders(
             name=folder.name,
             parent_id=folder.parent_id,
             project_id=folder.project_id,
-            archive_id=folder.archive_id,
             project_name=project_name,
-            archive_name=archive_name,
+            **_archive_link(folder.archive_id, archives),
             is_external=folder.is_external,
             external_path=folder.external_path,
             external_readonly=folder.external_readonly,
@@ -1070,7 +1106,7 @@ async def list_folders(
         folder_map[folder.id] = folder_item
 
     # Link children to parents
-    for folder, _, _ in rows:
+    for folder, _ in rows:
         folder_item = folder_map[folder.id]
         if folder.parent_id is None:
             root_folders.append(folder_item)
@@ -1106,12 +1142,13 @@ async def list_folders(
 async def get_folders_by_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get all folders linked to a specific project, those the user may see (#3201)."""
     user, _ = auth_result
@@ -1125,6 +1162,7 @@ async def get_folders_by_project(
         .order_by(LibraryFolder.name)
     )
     rows = [row for row in result.all() if visible is None or row[0].id in visible]
+    archives = await _linkable_archives(db, [row[0].archive_id for row in rows], printer_scope)
 
     folders = []
     for folder, project_name in rows:
@@ -1151,9 +1189,8 @@ async def get_folders_by_project(
                 name=folder.name,
                 parent_id=folder.parent_id,
                 project_id=folder.project_id,
-                archive_id=folder.archive_id,
                 project_name=project_name,
-                archive_name=None,
+                **_archive_link(folder.archive_id, archives),
                 is_external=folder.is_external,
                 external_path=folder.external_path,
                 external_readonly=folder.external_readonly,
@@ -1173,28 +1210,29 @@ async def get_folders_by_project(
 async def get_folders_by_archive(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get all folders linked to a specific archive, those the user may see (#3201)."""
     user, _ = auth_result
+    archives = await _linkable_archives(db, [archive_id], printer_scope)
+    if archive_id not in archives:
+        return []
     index = await _load_index(db, user)
     visible = visible_folder_ids(index, user)
     own_files = _own_files_filter(user)
     result = await db.execute(
-        select(LibraryFolder, PrintArchive.print_name)
-        .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
-        .where(LibraryFolder.archive_id == archive_id)
-        .order_by(LibraryFolder.name)
+        select(LibraryFolder).where(LibraryFolder.archive_id == archive_id).order_by(LibraryFolder.name)
     )
-    rows = [row for row in result.all() if visible is None or row[0].id in visible]
+    rows = [folder for folder in result.scalars().all() if visible is None or folder.id in visible]
 
     folders = []
-    for folder, archive_name in rows:
+    for folder in rows:
         # Get file count + latest file activity (#1770/#2680) in one trip. Prefer
         # the real on-disk mtime (external scans), fall back to the DB updated_at.
         agg_result = await db.execute(
@@ -1218,9 +1256,8 @@ async def get_folders_by_archive(
                 name=folder.name,
                 parent_id=folder.parent_id,
                 project_id=folder.project_id,
-                archive_id=folder.archive_id,
                 project_name=None,
-                archive_name=archive_name,
+                **_archive_link(folder.archive_id, archives),
                 is_external=folder.is_external,
                 external_path=folder.external_path,
                 external_readonly=folder.external_readonly,
@@ -1243,6 +1280,7 @@ async def create_folder(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
     actor: User | ApiKeyActor | None = RequestActor,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Create a new folder, owned by the user who makes it (#3201)."""
     # A read_own user may only create inside their own or a shared folder.
@@ -1259,13 +1297,9 @@ async def create_folder(
         project_name = project.name
 
     # Verify archive exists if specified
-    archive_name = None
-    if data.archive_id is not None:
-        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
-        archive = archive_result.scalar_one_or_none()
-        if not archive:
-            raise HTTPException(status_code=404, detail="Archive not found")
-        archive_name = archive.print_name
+    archives = await _linkable_archives(db, [data.archive_id], printer_scope)
+    if data.archive_id is not None and data.archive_id not in archives:
+        raise HTTPException(status_code=404, detail="Archive not found")
 
     folder = LibraryFolder(
         name=data.name,
@@ -1286,9 +1320,8 @@ async def create_folder(
         name=folder.name,
         parent_id=folder.parent_id,
         project_id=folder.project_id,
-        archive_id=folder.archive_id,
         project_name=project_name,
-        archive_name=archive_name,
+        **_archive_link(folder.archive_id, archives),
         is_external=folder.is_external,
         external_path=folder.external_path,
         external_readonly=folder.external_readonly,
@@ -1307,12 +1340,13 @@ async def create_folder(
 async def get_folder(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get a folder by ID; 404 for a folder the user may not see (#3201)."""
     user, _ = auth_result
@@ -1321,9 +1355,8 @@ async def get_folder(
     if visible is not None and folder_id not in visible:
         raise HTTPException(status_code=404, detail="Folder not found")
     result = await db.execute(
-        select(LibraryFolder, Project.name, PrintArchive.print_name)
+        select(LibraryFolder, Project.name)
         .outerjoin(Project, LibraryFolder.project_id == Project.id)
-        .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
         .where(LibraryFolder.id == folder_id)
     )
     row = result.one_or_none()
@@ -1331,7 +1364,8 @@ async def get_folder(
     if not row:
         raise HTTPException(status_code=404, detail="Folder not found")
 
-    folder, project_name, archive_name = row
+    folder, project_name = row
+    archives = await _linkable_archives(db, [folder.archive_id], printer_scope)
 
     # Get file count + latest file activity (#1770) in one trip
     agg_result = await db.execute(
@@ -1353,9 +1387,8 @@ async def get_folder(
         name=folder.name,
         parent_id=folder.parent_id,
         project_id=folder.project_id,
-        archive_id=folder.archive_id,
         project_name=project_name,
-        archive_name=archive_name,
+        **_archive_link(folder.archive_id, archives),
         is_external=folder.is_external,
         external_path=folder.external_path,
         external_readonly=folder.external_readonly,
@@ -1376,7 +1409,7 @@ _README_PREFERRED_STEMS = ("readme", "description")
 async def get_folder_readme(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -1437,12 +1470,13 @@ async def update_folder(
     folder_id: int,
     data: FolderUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update a folder.
 
@@ -1502,11 +1536,12 @@ async def update_folder(
     # Update archive_id (0 to unlink)
     if data.archive_id is not None:
         if data.archive_id == 0:
-            folder.archive_id = None
+            # A link the caller can't see is not theirs to remove.
+            if folder.archive_id in await _linkable_archives(db, [folder.archive_id], printer_scope):
+                folder.archive_id = None
         else:
             # Verify archive exists
-            archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
-            if not archive_result.scalar_one_or_none():
+            if data.archive_id not in await _linkable_archives(db, [data.archive_id], printer_scope):
                 raise HTTPException(status_code=404, detail="Archive not found")
             folder.archive_id = data.archive_id
 
@@ -1530,22 +1565,18 @@ async def update_folder(
 
     # Get project and archive names
     project_name = None
-    archive_name = None
     if folder.project_id:
         project_result = await db.execute(select(Project.name).where(Project.id == folder.project_id))
         project_name = project_result.scalar()
-    if folder.archive_id:
-        archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == folder.archive_id))
-        archive_name = archive_result.scalar()
+    archives = await _linkable_archives(db, [folder.archive_id], printer_scope)
 
     return FolderResponse(
         id=folder.id,
         name=folder.name,
         parent_id=folder.parent_id,
         project_id=folder.project_id,
-        archive_id=folder.archive_id,
         project_name=project_name,
-        archive_name=archive_name,
+        **_archive_link(folder.archive_id, archives),
         is_external=folder.is_external,
         external_path=folder.external_path,
         external_readonly=folder.external_readonly,
@@ -1562,7 +1593,7 @@ async def update_folder(
 async def delete_folder(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -2146,6 +2177,7 @@ async def scan_external_folder(
     # every scan even though the file is still there. os.path.exists keeps
     # such records; genuinely-deleted files (absent from disk) are still
     # cleaned up. External file_path is the absolute on-disk path.
+    vanished_ids: list[int] = []
     for path_str, db_file in existing_files.items():
         if path_str not in found_paths and not os.path.exists(path_str):
             # Clean up thumbnail if we generated one
@@ -2160,8 +2192,17 @@ async def scan_external_folder(
             # its photos go with it rather than being orphaned under an id
             # nothing points at any more (#3077).
             remove_library_photos_dir(db_file.id)
-            await db.delete(db_file)
-            removed += 1
+            vanished_ids.append(db_file.id)
+    if vanished_ids:
+        # Same clean-up as every other hard delete of library rows, once for
+        # all of them: a share that lost thousands of files must not run it
+        # per file while holding the SQLite write lock.
+        from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
+
+        await delete_dependent_variants(db, vanished_ids)
+        await release_queue_references(db, vanished_ids)
+        await db.execute(delete(LibraryFile).where(LibraryFile.id.in_(vanished_ids)))
+        removed += len(vanished_ids)
 
     # Remove empty subfolders whose directories no longer exist on disk
     # Process deepest-first by sorting on path depth (descending)
@@ -2231,7 +2272,7 @@ async def list_files(
     recursive: bool = False,
     tag_ids: list[int] = Query(default_factory=list),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -2527,7 +2568,7 @@ async def upload_file(
             if generate_stl_thumbnails:
                 try:
                     if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
-                        thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+                        thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
                 except OSError:
                     pass
 
@@ -2623,6 +2664,9 @@ async def extract_zip_file(
     folders_created = 0
     folder_cache: dict[str, int] = {}  # path -> folder_id
 
+    # The upload may have taken a while; and again before each file below.
+    await require_actor_still_exists(db, actor)
+
     # If create_folder_from_zip is True, create a folder named after the ZIP file
     zip_folder_id = folder_id
     logger.info(
@@ -2675,6 +2719,9 @@ async def extract_zip_file(
             ]
 
             for zip_path in file_list:
+                # An extract can run for minutes; a user deleted meanwhile must
+                # not have the rest saved under their id (SQLite reuses it).
+                await require_actor_still_exists(db, actor)
                 try:
                     # Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)
                     target_folder_id = zip_folder_id
@@ -2819,7 +2866,7 @@ async def extract_zip_file(
                         # stub STLs would otherwise log one debug line per
                         # file via the empty-mesh branch in trimesh.load.
                         if generate_stl_thumbnails and len(file_content) >= MIN_USABLE_STL_BYTES:
-                            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+                            thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
 
                     # Create database entry (store relative paths for portability)
                     library_file = LibraryFile(
@@ -2864,6 +2911,8 @@ async def extract_zip_file(
 
     except zipfile.BadZipFile:
         raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
+    except HTTPException:
+        raise
     except Exception as e:
         logger.error("ZIP extraction failed: %s", e, exc_info=True)
         raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
@@ -2882,7 +2931,7 @@ async def extract_zip_file(
 async def batch_generate_stl_thumbnails(
     request: BatchThumbnailRequest,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -2964,7 +3013,7 @@ async def batch_generate_stl_thumbnails(
             if target_file.file_type == "pdf":
                 thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
             else:
-                thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+                thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
 
             if thumbnail_path:
                 # Update database with relative path
@@ -3087,6 +3136,7 @@ async def combine_files(
     # The preview is embedded as Metadata/thumbnail.png by combine_parts_to_3mf,
     # so ThreeMFParser picks it up here like any other 3MF's thumbnail. Loading
     # the combined file back to render one would expand every copy.
+    await require_actor_still_exists(db, actor)  # the combine may have taken a while
     library_file, _ = await save_3mf_bytes_to_library(
         db,
         file_bytes=content,
@@ -3153,17 +3203,19 @@ async def add_files_to_queue(
     # silently declines when it finds nothing, because there, owning no such
     # printer is the user's situation rather than their mistake -- the file
     # still queues, as the unassigned row it has always been.
-    active_models: set[str] = set()
+    # Keyed without regard to case, as the scheduler matches a model, to the
+    # printers' own spelling, which the items store.
+    active_models: dict[str, str] = {}
     if request.printer_id is None:
-        active_models = {
-            model
-            for (model,) in (
-                await db.execute(select(Printer.model).where(Printer.is_active == True).distinct())  # noqa: E712
-            ).all()
-            if model
-        }
-        if target_model_norm and target_model_norm not in active_models:
-            raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+        for (model,) in (
+            await db.execute(select(Printer.model).where(Printer.is_active == True).order_by(Printer.id))  # noqa: E712
+        ).all():
+            if model:
+                active_models.setdefault(model.lower(), model)
+        if target_model_norm:
+            if target_model_norm.lower() not in active_models:
+                raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+            target_model_norm = active_models[target_model_norm.lower()]
 
     # Get all requested files
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
@@ -3176,7 +3228,7 @@ async def add_files_to_queue(
     # about which ids exist. Ownerless rows need LIBRARY_READ_ALL, matching
     # _ensure_library_file_visible.
     if actor is not None and not actor.has_permission(Permission.LIBRARY_READ_ALL.value):
-        files = {fid: f for fid, f in files.items() if f.created_by_id == actor.id}
+        files = {fid: f for fid, f in files.items() if f.created_by_id is not None and f.created_by_id == actor.id}
 
     # Project attribution (#1897): a file queued from a project-linked folder
     # inherits that project, so the resulting archive counts toward the
@@ -3196,6 +3248,9 @@ async def add_files_to_queue(
     # There is no per-job ask-for-outcome toggle on a bulk add, so the rows take
     # the same default the print dialog seeds itself from (#1898).
     confirm_outcome = await confirm_outcome_for_new_queue_item(db)
+    # No print options on a bulk add either: the install's defaults, as the
+    # print dialog would have offered them.
+    print_options = await default_print_options(db)
 
     for file_id in request.file_ids:
         lib_file = files.get(file_id)
@@ -3259,11 +3314,16 @@ async def add_files_to_queue(
                     item_target_model = target_model_norm
                 else:
                     inferred = resolve_variant_model(lib_file)
-                    item_target_model = inferred if inferred in active_models else None
+                    item_target_model = active_models.get(inferred.lower()) if inferred else None
 
             # Filament the scheduler must match before handing a model-based
             # item to hardware. Without it the item goes to whichever printer
             # of that model is idle, whatever is loaded in it.
+            # The plate that prints: added without one, a multi-plate file
+            # prints its default plate. Stored on the item, so the filament a
+            # printer must have, the AMS mapping and the unmatched-filament
+            # check all read that plate rather than every plate.
+            printed_plate = await asyncio.to_thread(printed_plate_of_multi_plate_file, file_path)
             required_filament_types = None
             if item_target_model:
                 # POST /queue/'s own extractor, borrowed rather than
@@ -3275,7 +3335,7 @@ async def add_files_to_queue(
                 # bulk add of a few hundred -- especially from an external
                 # folder on a NAS -- the zip reads add up to a stall the whole
                 # event loop takes, status ingest included.
-                filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path)
+                filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path, printed_plate)
                 if filament_types:
                     required_filament_types = json.dumps(filament_types)
 
@@ -3285,6 +3345,7 @@ async def add_files_to_queue(
                 printer_id=item_printer_id,
                 target_model=item_target_model,
                 required_filament_types=required_filament_types,
+                plate_id=printed_plate,
                 library_file_id=file_id,
                 project_id=lib_file.project_id
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
@@ -3297,6 +3358,7 @@ async def add_files_to_queue(
                 created_by_id=actor.id if actor else None,
                 # Waits for someone to start it unless they may print without review (#1620)
                 manual_start=review_required,
+                **print_options,
             )
             db.add(queue_item)
 
@@ -3329,6 +3391,7 @@ async def add_files_to_queue(
         )
 
     await db.commit()
+    await announce_queue_jobs_added(db, [a.queue_item_id for a in added])
 
     return AddToQueueResponse(added=added, errors=errors)
 
@@ -3337,7 +3400,7 @@ async def add_files_to_queue(
 async def get_library_file_plates(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -3530,6 +3593,8 @@ async def get_library_file_plates(
                                     "slot_id": int(filament_id),
                                     "type": filament_type,
                                     "color": filament_color,
+                                    # The preset the slicer assigned (GFA01, ...): names the product line
+                                    "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                 }
@@ -3628,7 +3693,7 @@ async def get_library_file_plate_thumbnail(
     file_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -3716,7 +3781,7 @@ async def get_library_file_filament_requirements(
     request_id: str | None = None,
     full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -3886,7 +3951,7 @@ async def get_library_file_filament_requirements(
             filaments.sort(key=lambda x: x["slot_id"])
 
             # Enrich with nozzle mapping for dual-nozzle printers
-            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
             if nozzle_mapping:
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
@@ -4886,6 +4951,10 @@ async def slice_and_persist(
     if extra_metadata:
         metadata.update(extra_metadata)
 
+    from backend.app.services.slice_dispatch import slice_dispatch as _dispatch
+
+    # Nobody's, when the user was deleted while it sliced: the id may be someone else's now.
+    current_user_id = _dispatch.owner_now(job_id, current_user_id)
     new_file = LibraryFile(
         folder_id=folder_id,
         is_external=out_is_external,
@@ -5071,6 +5140,10 @@ async def slice_and_persist_as_archive(
     )
     new_printer_id = None if is_cross_model_reslice else source_archive.printer_id
 
+    from backend.app.services.slice_dispatch import slice_dispatch as _dispatch
+
+    # Nobody's, when the user was deleted while it sliced: the id may be someone else's now.
+    current_user_id = _dispatch.owner_now(job_id, current_user_id)
     new_archive = PrintArchive(
         printer_id=new_printer_id,
         project_id=source_archive.project_id,
@@ -5104,9 +5177,13 @@ async def slice_and_persist_as_archive(
         bed_type=parsed_metadata.get("bed_type") or source_archive.bed_type,
         makerworld_url=source_archive.makerworld_url,
         designer=source_archive.designer,
-        # Sliced-but-not-printed: keep status default ("completed") so it
-        # surfaces in the normal archives list, but do not stamp
-        # started/completed_at — the user hasn't actually printed it yet.
+        # Sliced, not printed: "archived", as archive_print files an unprinted
+        # 3MF. It is listed under "Not printed" and is not counted as a
+        # completed print; no started/completed_at either.
+        status="archived",
+        # Priced like any archive with grams, for the card's estimate.
+        # Statistics read the print log, so nothing is counted that did not print.
+        cost=await estimate_filament_cost(db, filament_g or None, new_filament_type),
         extra_data=metadata,
         created_by_id=current_user_id,
     )
@@ -5273,7 +5350,7 @@ async def print_library_file(
 async def get_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5372,7 +5449,7 @@ async def update_file(
     file_id: int,
     data: FileUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -5406,12 +5483,21 @@ async def update_file(
         # and _without_print_name strips the embedded 3MF Title on import (#1489).
 
     if data.folder_id is not None:
-        if data.folder_id == 0:
-            file.folder_id = None
-        else:
+        target_folder = None
+        if data.folder_id != 0:
             # library:update_all moves into any folder, as before (#3201).
-            await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
-            file.folder_id = data.folder_id
+            target_folder = await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
+        # Into or out of an external folder the bytes have to move too, and a
+        # read-only one refuses them: that is the move route's work. Here only
+        # folder_id changed, so the file showed in a folder its bytes were not
+        # in (F8).
+        unchanged = (target_folder.id if target_folder is not None else None) == file.folder_id
+        if not unchanged and (file.is_external or (target_folder is not None and target_folder.is_external)):
+            raise HTTPException(
+                status_code=400,
+                detail="Move a file into or out of an external folder with POST /api/v1/library/files/move",
+            )
+        file.folder_id = target_folder.id if target_folder is not None else None
 
     if data.project_id is not None:
         if data.project_id == 0:
@@ -5443,7 +5529,7 @@ async def update_file(
 async def delete_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -5501,7 +5587,7 @@ async def delete_file(
 async def download_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5528,7 +5614,7 @@ async def download_file(
 async def create_library_slicer_token(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5590,7 +5676,7 @@ async def download_library_file_for_slicer(
 async def get_thumbnail(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5631,7 +5717,7 @@ async def upload_preview_thumbnail(
     file_id: int,
     thumbnail: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -5732,7 +5818,7 @@ async def upload_file_photo(
     file_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -5788,7 +5874,7 @@ async def get_file_photo(
     file_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5821,7 +5907,7 @@ async def delete_file_photo(
     file_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -5867,7 +5953,7 @@ async def get_gcode(
     file_id: int,
     plate: int | None = None,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5930,7 +6016,7 @@ async def get_gcode(
 async def move_files(
     data: FileMoveRequest,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -6036,7 +6122,7 @@ async def move_files(
 async def bulk_delete(
     data: BulkDeleteRequest,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -6136,7 +6222,7 @@ async def bulk_delete(
 @router.get("/stats")
 async def get_library_stats(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,

+ 3 - 3
backend/app/api/routes/library_tags.py

@@ -35,7 +35,7 @@ from sqlalchemy import delete, distinct, func, select
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission, require_permission_if_auth_enabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryTag
@@ -68,7 +68,7 @@ def _name_key(name: str) -> str:
 @router.get("/", response_model=list[TagResponse])
 async def list_tags(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -209,7 +209,7 @@ async def delete_tag(
 async def bulk_assign(
     payload: TagBulkAssignRequest,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,

+ 6 - 6
backend/app/api/routes/library_trash.py

@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission, require_permission_if_auth_enabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFolder
@@ -90,7 +90,7 @@ async def list_trash(
     limit: int = Query(default=100, ge=1, le=500),
     offset: int = Query(default=0, ge=0),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -153,7 +153,7 @@ async def list_trash(
 async def _load_trashed_file(
     db: AsyncSession,
     file_id: int,
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_modify_all: bool,
 ) -> LibraryFile:
     """Fetch a trashed file, enforcing ownership for non-admins."""
@@ -176,7 +176,7 @@ async def _load_trashed_file(
 async def restore_from_trash(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -193,7 +193,7 @@ async def restore_from_trash(
 async def hard_delete_from_trash(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -210,7 +210,7 @@ async def hard_delete_from_trash(
 @router.delete("/trash", response_model=EmptyTrashResponse)
 async def empty_trash(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,

+ 9 - 9
backend/app/api/routes/library_variants.py

@@ -43,7 +43,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.library import FileVariantGroup, LibraryFile
@@ -97,7 +97,7 @@ def resolve_variant_model(lib_file: LibraryFile, explicit: str | None = None) ->
 async def _load_files(
     db: AsyncSession,
     file_ids: list[int],
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_access_all: bool,
 ) -> dict[int, LibraryFile]:
     """Fetch the caller's visible, untrashed files by id."""
@@ -187,7 +187,7 @@ async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> b
 async def create_variant_group(
     payload: VariantGroupCreate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -242,7 +242,7 @@ async def create_variant_group(
 async def get_group_for_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -269,7 +269,7 @@ async def get_group_for_file(
 async def get_variant_group(
     group_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -284,7 +284,7 @@ async def update_variant_group(
     group_id: int,
     payload: VariantGroupUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -324,7 +324,7 @@ async def add_variant_group_member(
     payload: VariantGroupMemberRequest,
     group_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -380,7 +380,7 @@ async def remove_variant_group_member(
     group_id: int,
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -407,7 +407,7 @@ async def remove_variant_group_member(
 async def delete_variant_group(
     group_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,

+ 3 - 1
backend/app/api/routes/local_backup.py

@@ -5,7 +5,7 @@ import logging
 from fastapi import APIRouter, Path
 from fastapi.responses import FileResponse, JSONResponse
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireAdminIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.services.local_backup import local_backup_service
@@ -74,6 +74,7 @@ async def list_backups(
 @router.get("/backups/{filename}/download")
 async def download_backup(
     filename: str = Path(..., description="Backup filename to download"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
     """Download a specific backup file."""
@@ -91,6 +92,7 @@ async def download_backup(
 @router.post("/backups/{filename}/restore")
 async def restore_backup(
     filename: str = Path(..., description="Backup filename to restore"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
     """Restore from a scheduled backup file on the server."""

+ 59 - 16
backend/app/api/routes/maintenance.py

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
 
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -288,6 +289,47 @@ async def restore_default_maintenance_types(
 # ============== Printer Maintenance ==============
 
 
+def maintenance_alert_values(item: MaintenanceStatus) -> tuple[float, float, str]:
+    """What the MQTT relay's maintenance/alert reports: (value, threshold, unit).
+
+    Print-hour items count hours since the last maintenance against the
+    interval; time-based ones count days, and their interval_hours holds days.
+    """
+    if item.interval_type == "days":
+        return (item.days_since_maintenance or 0.0, item.interval_hours, "days")
+    return (item.hours_since_maintenance, item.interval_hours, "hours")
+
+
+async def _get_or_create_maintenance_item(db: AsyncSession, printer_id: int, type_id: int) -> PrinterMaintenance:
+    """The printer's row for this type, created with defaults if missing.
+
+    Insert first, in a savepoint: two reads of a new printer (two dashboards,
+    or a dashboard and the background check) each used to create the row. The
+    unique index now refuses the second, which takes the first's (FO-C14).
+    """
+    item = PrinterMaintenance(
+        printer_id=printer_id,
+        maintenance_type_id=type_id,
+        enabled=True,
+        last_performed_hours=0.0,
+    )
+    try:
+        async with db.begin_nested():
+            db.add(item)
+            await db.flush()
+        return item
+    except IntegrityError:
+        existing = await db.scalar(
+            select(PrinterMaintenance).where(
+                PrinterMaintenance.printer_id == printer_id,
+                PrinterMaintenance.maintenance_type_id == type_id,
+            )
+        )
+        if existing is None:
+            raise
+        return existing
+
+
 async def _get_printer_maintenance_internal(
     printer_id: int,
     db: AsyncSession,
@@ -345,21 +387,15 @@ async def _get_printer_maintenance_internal(
             if not maint_type.is_system:
                 continue
 
-            # Create default entry for this printer/type
-            item = PrinterMaintenance(
-                printer_id=printer_id,
-                maintenance_type_id=maint_type.id,
-                enabled=True,
-                last_performed_hours=0.0,
-            )
-            db.add(item)
-            await db.flush()
+            # Create default entry for this printer/type, or take the one a
+            # concurrent read just created.
+            item = await _get_or_create_maintenance_item(db, printer_id, maint_type.id)
 
-            interval = maint_type.default_interval_hours
-            interval_type = default_interval_type
-            enabled = True
-            last_performed_hours = 0.0
-            last_performed_at = None
+            interval = item.custom_interval_hours or maint_type.default_interval_hours
+            interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
+            enabled = item.enabled
+            last_performed_hours = item.last_performed_hours
+            last_performed_at = item.last_performed_at
             item_id = item.id
 
         # Calculate status based on interval type
@@ -541,7 +577,13 @@ async def assign_maintenance_type(
         last_performed_hours=0.0,
     )
     db.add(item)
-    await db.commit()
+    try:
+        await db.commit()
+    except IntegrityError:
+        # A concurrent assign (a double click) passed the check above too; the
+        # unique index refuses this one (FO-C14).
+        await db.rollback()
+        raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer") from None
 
     # Re-fetch with relationship loaded for response serialization
     from sqlalchemy.orm import selectinload
@@ -636,7 +678,8 @@ async def perform_maintenance(
 
     # Calculate status
     interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
-    interval_type = getattr(item.maintenance_type, "interval_type", "hours") or "hours"
+    # The item's own interval type first, as the status list reads it (dbcol-09).
+    interval_type = item.custom_interval_type or getattr(item.maintenance_type, "interval_type", "hours") or "hours"
     hours_since = current_hours - item.last_performed_hours
     hours_until = interval - hours_since
 

+ 3 - 0
backend/app/api/routes/makerworld.py

@@ -35,6 +35,7 @@ from backend.app.core.auth import (
     ApiKeyActor,
     RequestActor,
     RequirePermissionIfAuthEnabled,
+    require_actor_still_exists,
     require_auth_if_enabled,
     require_permission_if_auth_enabled,
     security,
@@ -426,6 +427,8 @@ async def import_instance(
     # there as on the manifest-supplied name.
     filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
 
+    # Not saved for a user deleted during the download (SQLite reuses the id).
+    await require_actor_still_exists(db, actor)
     # Credited to the key's owner for an API key. Credential identity is
     # resolved inside the provider.
     library_file, was_existing = await save_3mf_bytes_to_library(

+ 3 - 1
backend/app/api/routes/manyfold.py

@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.api.routes.library import save_3mf_bytes_to_library, validate_print_file_upload
 from backend.app.api.routes.settings import set_setting
-from backend.app.core.auth import ApiKeyActor, RequestActor, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import ApiKeyActor, RequestActor, RequirePermissionIfAuthEnabled, require_actor_still_exists
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile
@@ -344,6 +344,8 @@ async def import_file(
 
     filename = _library_filename(file["filename"], file_id)
     validate_print_file_upload(filename, data)
+    # Not saved for a user deleted during the download (SQLite reuses the id).
+    await require_actor_still_exists(db, actor)
     folder_id = await _import_folder_id(db, body.folder_id, actor)
     library_file, was_existing = await save_3mf_bytes_to_library(
         db,

+ 61 - 26
backend/app/api/routes/mfa.py

@@ -34,13 +34,14 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Requ
 from fastapi.responses import RedirectResponse
 from jwt import PyJWKClient
 from passlib.context import CryptContext
-from sqlalchemy import delete, select, update
+from sqlalchemy import delete, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload, undefer
 
 from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
 from backend.app.api.routes.settings import get_setting, set_setting
 from backend.app.core.auth import (
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     create_access_token,
     get_current_active_user,
@@ -48,6 +49,8 @@ from backend.app.core.auth import (
     get_user_by_username,
     is_auth_enabled,
     resolve_session_max_minutes,
+    session_dated,
+    token_outlived_its_session,
     verify_password,
 )
 from backend.app.core.database import get_db
@@ -65,7 +68,6 @@ from backend.app.schemas.auth import (
     EmailOTPDisableRequest,
     EmailOTPEnableConfirmRequest,
     EmailOTPSendRequest,
-    GroupBrief,
     LoginResponse,
     OIDCAuthorizeResponse,
     OIDCExchangeRequest,
@@ -160,9 +162,9 @@ def _etag_matches(if_none_match: str | None, etag_raw: str | None) -> bool:
 def _as_utc(dt: datetime) -> datetime:
     """Return *dt* with UTC timezone attached.
 
-    SQLite/aiosqlite strips timezone info when reading DateTime(timezone=True)
-    columns back – the stored value is always UTC, so we just re-attach the
-    info when doing Python-level comparisons.
+    DateTime columns come back naive on both SQLite and PostgreSQL – the
+    stored value is always UTC, so we just re-attach the info when doing
+    Python-level comparisons.
     """
     return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
 
@@ -194,17 +196,12 @@ router = APIRouter(prefix="/auth", tags=["2fa", "oidc"])
 # Helper: user response
 # ---------------------------------------------------------------------------
 def _user_to_response(user: User) -> UserResponse:
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        email=user.email,
-        role=user.role,
-        is_active=user.is_active,
-        is_admin=user.is_admin,
-        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
-        permissions=sorted(user.get_permissions()),
-        created_at=user.created_at.isoformat(),
-    )
+    # The auth routes' builder, not a copy: the copy here left out
+    # ``auth_source``, so a 2FA or OIDC login reported LDAP and OIDC users as
+    # local and the page offered them "Change Password" (PC-C9).
+    from backend.app.api.routes.auth import _user_to_response as build
+
+    return build(user)
 
 
 # ---------------------------------------------------------------------------
@@ -283,12 +280,15 @@ async def consume_pre_auth_token(db: AsyncSession, token: str, challenge_id: str
             AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
             AuthEphemeralToken.expires_at > now,
         )
-        .returning(AuthEphemeralToken.username, AuthEphemeralToken.challenge_id)
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.challenge_id, AuthEphemeralToken.created_at)
     )
     row = result.one_or_none()
     if row is None:
         return None
-    username, stored_challenge_id = row
+    username, stored_challenge_id, minted_at = row
+    if await token_outlived_its_session(db, username, minted_at):
+        await db.commit()  # spent all the same
+        return None
     # Enforce client binding: if the token was issued with a challenge_id,
     # the caller must supply the matching value.
     if stored_challenge_id is not None and stored_challenge_id != challenge_id:
@@ -314,7 +314,7 @@ async def peek_pre_auth_token(db: AsyncSession, token: str, challenge_id: str |
         )
     )
     eph = result.scalar_one_or_none()
-    if eph is None:
+    if eph is None or await token_outlived_its_session(db, eph.username, eph.created_at):
         return None
     # Enforce client binding: if the token was issued with a challenge_id the
     # cookie must match.  Treat a mismatch as if the token doesn't exist.
@@ -924,6 +924,7 @@ async def enable_email_otp(
             # Reuse the nonce field to store the code hash
             nonce=code_hash,
             expires_at=now + timedelta(minutes=10),
+            **session_dated(current_user.username),
         )
     )
     await db.commit()
@@ -982,7 +983,7 @@ async def confirm_enable_email_otp(
         )
     )
     eph = peek_result.scalar_one_or_none()
-    if eph is None:
+    if eph is None or await token_outlived_its_session(db, eph.username, eph.created_at):
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired setup token")
 
     code_hash = eph.nonce  # code hash stored in the nonce field
@@ -1273,11 +1274,14 @@ async def verify_2fa(
 async def admin_disable_2fa(
     user_id: int,
     body: AdminDisable2FARequest = Body(default_factory=AdminDisable2FARequest),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> dict:
     """Admin endpoint: disable all 2FA for a given user.
 
+    Admin-only, like every other change to a user account (users.update_user).
+
     Nit 3: Requires the admin's own password as a re-auth step (matching how
     disable_email_otp protects a user's own 2FA removal). OIDC/LDAP-only admins
     (no local password_hash) are exempt.
@@ -1287,6 +1291,14 @@ async def admin_disable_2fa(
         if not body.admin_password or not verify_password(body.admin_password, current_user.password_hash):
             raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin password required")
 
+    target_user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
+    if target_user is None:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+
+    # Reported back so the Edit user dialog can say whether anything was removed.
+    totp = (await db.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))).scalar_one_or_none()
+    had_2fa = bool(totp is not None and totp.is_enabled) or await _get_email_2fa_enabled(db, user_id)
+
     # Delete TOTP record
     await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
 
@@ -1302,14 +1314,21 @@ async def admin_disable_2fa(
 
     # I2: Invalidate existing JWTs for the target user by bumping password_changed_at.
     # Without this, a stolen token remains valid after 2FA removal.
-    target_user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
-    if target_user:
-        target_user.password_changed_at = datetime.now(timezone.utc)
+    target_user.password_changed_at = datetime.now(timezone.utc)
+    # The WebSocket and camera tokens too: reusable for an hour, they would
+    # reconnect a closed dashboard straight away.
+    from backend.app.api.routes.users import drop_pending_tokens
+
+    await drop_pending_tokens(db, target_user.username)
 
     await db.commit()
+    # And their open dashboards: they are never signed in again.
+    from backend.app.core.websocket import ws_manager
+
+    await ws_manager.close_user(user_id)
     actor = current_user.username if current_user else "anonymous"
     logger.info("Admin %s disabled all 2FA for user_id=%d", actor, user_id)
-    return {"message": "2FA disabled for user"}
+    return {"message": "2FA disabled for user", "had_2fa": had_2fa}
 
 
 # ===========================================================================
@@ -1351,6 +1370,7 @@ async def list_all_oidc_providers(
 @router.post("/oidc/providers", response_model=OIDCProviderResponse, status_code=status.HTTP_201_CREATED)
 async def create_oidc_provider(
     body: OIDCProviderCreate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
@@ -1451,6 +1471,7 @@ async def _missing_group_names(db: AsyncSession, names: set[str]) -> set[str]:
 async def update_oidc_provider(
     provider_id: int,
     body: OIDCProviderUpdate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
@@ -1550,6 +1571,7 @@ async def update_oidc_provider(
 @router.delete("/oidc/providers/{provider_id}")
 async def delete_oidc_provider(
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> dict:
@@ -1612,6 +1634,7 @@ async def get_oidc_provider_icon(
 @router.delete("/oidc/providers/{provider_id}/icon", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_oidc_provider_icon(
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> Response:
@@ -1641,6 +1664,7 @@ async def delete_oidc_provider_icon(
 @router.post("/oidc/providers/{provider_id}/icon/refresh", response_model=OIDCProviderResponse)
 async def refresh_oidc_provider_icon(
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
@@ -2194,14 +2218,16 @@ async def oidc_exchange(
             AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,
             AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically
         )
-        .returning(AuthEphemeralToken.username)
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.created_at)
     )
     row = consume_result.one_or_none()
     if row is None:
         raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired OIDC exchange token")
 
-    (username,) = row
+    username, minted_at = row
     await db.commit()
+    if await token_outlived_its_session(db, username, minted_at):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired OIDC exchange token")
 
     user = await get_user_by_username(db, username)
     if not user or not user.is_active:
@@ -2300,6 +2326,15 @@ async def remove_oidc_link(
     link = result.scalar_one_or_none()
     if not link:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC link not found")
+    # A user created from an SSO login has no password: their last link is
+    # their only way to sign in.
+    if current_user.auth_source == "oidc" and not current_user.password_hash:
+        links = await db.execute(select(func.count()).where(UserOIDCLink.user_id == current_user.id))
+        if links.scalar_one() <= 1:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="This is the only way this account can sign in; it can't be unlinked",
+            )
 
     await db.delete(link)
     await db.commit()

+ 8 - 0
backend/app/api/routes/notification_templates.py

@@ -37,6 +37,14 @@ EVENT_NAMES = {
     "ai_failure_detection": "AI Failure Detection",
     "plate_not_empty": "Plate Not Empty",
     "plate_clear_required": "Plate Clear Required",
+    "print_confirm_request": "Print Outcome Confirmation",
+    "print_missing_spool_assignment": "Missing Spool Assignment",
+    "bed_cooled": "Bed Cooled",
+    "ams_humidity_high": "AMS Humidity High",
+    "ams_temperature_high": "AMS Temperature High",
+    "ams_drying_suspended": "Auto-Drying Suspended",
+    "ha_sensor_alert": "Printer Sensor Alert",
+    "location_ha_sensor_alert": "Storage Location Sensor Alert",
     "filament_low": "Filament Low",
     "maintenance_due": "Maintenance Due",
     "first_layer_complete": "First Layer Complete",

+ 205 - 57
backend/app/api/routes/notifications.py

@@ -11,9 +11,16 @@ from fastapi.responses import FileResponse
 from sqlalchemy import delete, desc, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, ScopedCaller, require_notification_send
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    ScopedCaller,
+    probe_permissions_if_auth_enabled,
+    require_notification_send,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, ensure_printers_exist
 from backend.app.models.notification import NotificationLog, NotificationProvider
 from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
 from backend.app.models.user import User
@@ -33,6 +40,12 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.notify_client import NotifyError, notify_credentials
 from backend.app.services.notify_live_activities import notify_live_activities
 from backend.app.services.notify_widgets import notify_widgets
+from backend.app.services.secret_fields import (
+    SecretConfigError,
+    mask_config,
+    merge_config,
+    reject_placeholder,
+)
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.utils.notification_photos import find_notification_photo
 
@@ -62,14 +75,48 @@ async def _resync_reaction_poller():
         logger.warning("Telegram reaction poller resync failed: %s", e)
 
 
+async def delete_provider(db: AsyncSession, provider: NotificationProvider) -> bool:
+    """Delete a provider (the caller commits, then calls after_providers_deleted).
+
+    A Bambuddy Notify provider is first retired and its live activities and
+    widgets queued for remote cleanup, while the rows that name them still
+    exist. Returns whether it was one.
+    """
+    notify = provider.provider_type == "notify"
+    if notify:
+        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        # Retire this provider and capture owned IDs before FK cascade removes
+        # them. Queue remote cleanup; HTTP must never delay this request.
+        provider.enabled = False
+        await db.commit()
+        await notify_live_activities.schedule_cleanup(provider.id, config)
+        await notify_widgets.schedule_cleanup(provider.id, config)
+    await db.delete(provider)
+    return notify
+
+
+async def after_providers_deleted(*, notify: bool) -> None:
+    """What deleted providers leave to do once the delete is committed."""
+    if notify:
+        _notify_providers_changed()
+    await _resync_reaction_poller()
+
+
+def _stored_config(provider: NotificationProvider) -> dict:
+    return json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+
+
 def _provider_to_dict(provider: NotificationProvider) -> dict:
-    """Convert a NotificationProvider model to a response dictionary."""
+    """Convert a NotificationProvider model to a response dictionary.
+
+    Secrets are write-only: a set one is shown as the placeholder.
+    """
     return {
         "id": provider.id,
         "name": provider.name,
         "provider_type": provider.provider_type,
         "enabled": provider.enabled,
-        "config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
+        "config": mask_config(_stored_config(provider)),
         "attach_photo": provider.attach_photo,
         # Print lifecycle events
         "on_print_start": provider.on_print_start,
@@ -147,14 +194,58 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
 # ============================================================================
 
 
+def _provider_in_scope(provider: NotificationProvider, printer_scope: PrinterScope) -> bool:
+    """Whether a caller limited to some printers may see and manage ``provider``.
+
+    Only one bound to one of their printers: a provider for all printers (or
+    another printer) delivers events of printers outside their groups.
+    """
+    if printer_scope.is_unrestricted:
+        return True
+    return provider.printer_id is not None and printer_scope.allows(provider.printer_id)
+
+
+def _log_scope_clause(printer_scope: PrinterScope):
+    """The log entries a caller limited to some printers sees (None: all).
+
+    Those of their printers, and those with no printer sent by a provider they
+    can see: an entry with no printer belongs to whoever owns its provider.
+    """
+    if printer_scope.printer_ids is None:
+        return None
+    own_providers = select(NotificationProvider.id).where(
+        NotificationProvider.printer_id.in_(printer_scope.printer_ids)
+    )
+    return NotificationLog.printer_id.in_(printer_scope.printer_ids) | (
+        NotificationLog.printer_id.is_(None) & NotificationLog.provider_id.in_(own_providers)
+    )
+
+
+async def _load_provider(db: AsyncSession, provider_id: int, printer_scope: PrinterScope) -> NotificationProvider:
+    provider = await db.get(NotificationProvider, provider_id)
+    if provider is None or not _provider_in_scope(provider, printer_scope):
+        raise HTTPException(status_code=404, detail="Notification provider not found")
+    return provider
+
+
+def _ensure_target_in_scope(printer_id: int | None, printer_scope: PrinterScope) -> None:
+    """A caller limited to some printers binds a provider to one of them, never to all."""
+    if printer_scope.is_unrestricted:
+        return
+    if printer_id is None:
+        raise HTTPException(status_code=400, detail="Choose one of your printers for this provider")
+    printer_scope.ensure(printer_id)
+
+
 @router.get("/", response_model=list[NotificationProviderResponse])
 async def list_notification_providers(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List all notification providers."""
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
-    providers = result.scalars().all()
+    providers = [p for p in result.scalars().all() if _provider_in_scope(p, printer_scope)]
 
     return [_provider_to_dict(provider) for provider in providers]
 
@@ -164,8 +255,15 @@ async def create_notification_provider(
     provider_data: NotificationProviderCreate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Create a new notification provider."""
+    _ensure_target_in_scope(provider_data.printer_id, printer_scope)
+    await ensure_printers_exist(db, [provider_data.printer_id])
+    try:
+        reject_placeholder(provider_data.config)
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
     if provider_data.provider_type.value == "notify":
         try:
             notify_credentials(provider_data.config)
@@ -256,10 +354,29 @@ async def test_notification_config(
     test_request: NotificationTestRequest,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Test notification configuration before saving."""
+    """Test notification configuration before saving.
+
+    With provider_id (an edited provider), the stored secrets fill in the
+    placeholders, for those who may edit providers.
+    """
+    config = test_request.config
+    try:
+        if test_request.provider_id is None:
+            reject_placeholder(config)
+        else:
+            if not can_update:
+                raise HTTPException(status_code=403, detail="Testing a saved provider requires notifications:update")
+            provider = await _load_provider(db, test_request.provider_id, printer_scope)
+            config = merge_config(
+                provider.provider_type, _stored_config(provider), test_request.provider_type.value, config
+            )
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
     success, message = await notification_service.send_test_notification(
-        test_request.provider_type.value, test_request.config, db, attach_photo=test_request.attach_photo
+        test_request.provider_type.value, config, db, attach_photo=test_request.attach_photo
     )
 
     return NotificationTestResponse(success=success, message=message)
@@ -269,10 +386,11 @@ async def test_notification_config(
 async def test_all_notification_providers(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Send a test notification to all enabled providers."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
-    providers = result.scalars().all()
+    providers = [p for p in result.scalars().all() if _provider_in_scope(p, printer_scope)]
 
     if not providers:
         return {"tested": 0, "success": 0, "failed": 0, "results": []}
@@ -331,9 +449,12 @@ async def get_notification_logs(
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get notification logs with optional filters."""
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
+    if (scope_clause := _log_scope_clause(printer_scope)) is not None:
+        query = query.where(scope_clause)
 
     # Apply filters
     if provider_id is not None:
@@ -363,6 +484,13 @@ async def get_notification_logs(
             providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
 
         provider = providers_cache[log.provider_id]
+        # A provider the caller can't see is not named, as it isn't listed, and
+        # neither is its error, which names its kind and destination.
+        hidden = not printer_scope.is_unrestricted and (
+            provider is None or not _provider_in_scope(provider, printer_scope)
+        )
+        if hidden:
+            provider = None
         response.append(
             NotificationLogResponse(
                 id=log.id,
@@ -373,7 +501,7 @@ async def get_notification_logs(
                 title=log.title,
                 message=log.message,
                 success=log.success,
-                error_message=log.error_message,
+                error_message=None if hidden else log.error_message,
                 printer_id=log.printer_id,
                 printer_name=log.printer_name,
                 created_at=log.created_at,
@@ -388,37 +516,44 @@ async def get_notification_log_stats(
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get notification log statistics."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+    # Counted over the entries the log list shows this caller.
+    window = [NotificationLog.created_at >= cutoff]
+    if (scope_clause := _log_scope_clause(printer_scope)) is not None:
+        window.append(scope_clause)
 
     # Total counts
-    total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
+    total_result = await db.execute(select(func.count(NotificationLog.id)).where(*window))
     total = total_result.scalar() or 0
 
     success_result = await db.execute(
-        select(func.count(NotificationLog.id)).where(
-            NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)
-        )
+        select(func.count(NotificationLog.id)).where(*window, NotificationLog.success.is_(True))
     )
     success_count = success_result.scalar() or 0
 
     # By event type
     event_result = await db.execute(
         select(NotificationLog.event_type, func.count(NotificationLog.id))
-        .where(NotificationLog.created_at >= cutoff)
+        .where(*window)
         .group_by(NotificationLog.event_type)
     )
     by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
 
     # By provider (need to join to get name)
     provider_result = await db.execute(
-        select(NotificationProvider.name, func.count(NotificationLog.id))
+        select(NotificationProvider, func.count(NotificationLog.id))
         .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
-        .where(NotificationLog.created_at >= cutoff)
-        .group_by(NotificationProvider.name)
+        .where(*window)
+        .group_by(NotificationProvider.id)
     )
-    by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
+    # Providers the caller can't see are counted together, not by name.
+    by_provider: dict[str, int] = {}
+    for provider, count in provider_result.all():
+        name = provider.name if _provider_in_scope(provider, printer_scope) else "Other"
+        by_provider[name] = by_provider.get(name, 0) + count
 
     return NotificationLogStats(
         total=total,
@@ -434,11 +569,16 @@ async def clear_notification_logs(
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Clear old notification logs."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
 
-    result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
+    statement = delete(NotificationLog).where(NotificationLog.created_at < cutoff)
+    # A caller limited to some printers clears only those printers' entries.
+    if (scope_clause := printer_scope.where_strict(NotificationLog.printer_id)) is not None:
+        statement = statement.where(scope_clause)
+    result = await db.execute(statement)
     await db.commit()
 
     deleted_count = result.rowcount
@@ -486,6 +626,15 @@ def _app_sender(caller: ScopedCaller) -> tuple[str, str]:
     return "anonymous", "app"
 
 
+def forget_app_senders(api_key_ids=(), user_ids=()) -> None:
+    """Drop deleted keys' and users' message counts: SQLite gives an id to the
+    next key or user created, which would start out throttled."""
+    for key_id in api_key_ids:
+        _app_message_times.pop(f"key:{key_id}", None)
+    for user_id in user_ids:
+        _app_message_times.pop(f"user:{user_id}", None)
+
+
 def _check_app_message_rate(key: str) -> None:
     times = _app_message_times[key]
     cutoff = time.monotonic() - APP_MESSAGE_WINDOW_SECONDS
@@ -501,14 +650,21 @@ async def send_app_message(
     data: AppMessage,
     db: AsyncSession = Depends(get_db),
     caller: ScopedCaller = Depends(require_notification_send()),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Send a message through every enabled channel that has "Messages from
     connected apps" on. For other applications, e.g. Bambuddy Orders; an API
-    key needs the "Send notifications" permission."""
+    key needs the "Send notifications" permission. A caller limited to some
+    printers reaches only the channels of those printers."""
     key, sender = _app_sender(caller)
     _check_app_message_rate(key)
     channels = await notification_service.on_app_message(
-        db, sender=sender, title=data.title, message=data.message, url=data.url
+        db,
+        sender=sender,
+        title=data.title,
+        message=data.message,
+        url=data.url,
+        allowed=lambda provider: _provider_in_scope(provider, printer_scope),
     )
     return AppMessageResult(channels=channels)
 
@@ -517,6 +673,7 @@ async def send_app_message(
 async def app_message_channels(
     db: AsyncSession = Depends(get_db),
     _: ScopedCaller = Depends(require_notification_send()),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """The enabled channels that deliver app messages: names and types only,
     so an app can tell its user where its messages will arrive."""
@@ -525,7 +682,11 @@ async def app_message_channels(
         .where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
         .order_by(NotificationProvider.name)
     )
-    return [AppMessageChannel(name=p.name, provider_type=p.provider_type) for p in rows.scalars()]
+    return [
+        AppMessageChannel(name=p.name, provider_type=p.provider_type)
+        for p in rows.scalars()
+        if _provider_in_scope(p, printer_scope)
+    ]
 
 
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
@@ -533,13 +694,10 @@ async def get_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get a specific notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
     return _provider_to_dict(provider)
 
@@ -550,22 +708,29 @@ async def update_notification_provider(
     update_data: NotificationProviderUpdate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update a notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
-    old_config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+    old_config = _stored_config(provider)
     old_type = provider.provider_type
 
     # Update only provided fields
     update_dict = update_data.model_dump(exclude_unset=True)
+    if "printer_id" in update_dict:
+        _ensure_target_in_scope(update_dict["printer_id"], printer_scope)
+    await ensure_printers_exist(db, [update_dict.get("printer_id")])
 
     effective_type = update_dict.get("provider_type") or provider.provider_type
     effective_type = getattr(effective_type, "value", effective_type)
+    if effective_type != old_type and update_dict.get("config") is None:
+        raise HTTPException(status_code=400, detail="A new provider type needs its own configuration")
+    if update_dict.get("config") is not None:
+        try:
+            update_dict["config"] = merge_config(old_type, old_config, effective_type, update_dict["config"])
+        except SecretConfigError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from None
     if effective_type == "notify":
         try:
             effective_config = update_dict.get("config", old_config)
@@ -584,7 +749,7 @@ async def update_notification_provider(
             setattr(provider, key, value)
 
     if old_type == "notify":
-        effective_config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        effective_config = _stored_config(provider)
         remove_widgets = (
             provider.provider_type != "notify"
             or not provider.enabled
@@ -610,7 +775,7 @@ async def update_notification_provider(
     await db.refresh(provider)
 
     response = _provider_to_dict(provider)
-    new_config = response["config"]
+    new_config = _stored_config(provider)
     changed = old_type != provider.provider_type or any(
         old_config.get(key) != new_config.get(key) for key in ("device_id", "token")
     )
@@ -632,31 +797,17 @@ async def delete_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Delete a notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
     name = provider.name
-    if provider.provider_type == "notify":
-        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
-        # Retire this provider and capture owned IDs before FK cascade removes
-        # them. Queue remote cleanup; HTTP must never delay this request.
-        provider.enabled = False
-        await db.commit()
-        await notify_live_activities.schedule_cleanup(provider_id, config)
-        await notify_widgets.schedule_cleanup(provider_id, config)
-    await db.delete(provider)
+    notify = await delete_provider(db, provider)
     await db.commit()
-
-    if provider.provider_type == "notify":
-        _notify_providers_changed()
+    await after_providers_deleted(notify=notify)
 
     logger.info("Deleted notification provider: %s", name)
-    await _resync_reaction_poller()
 
     return {"message": f"Notification provider '{name}' deleted"}
 
@@ -666,13 +817,10 @@ async def test_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Send a test notification using an existing provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     success, message = await notification_service.send_test_notification(

+ 13 - 6
backend/app/api/routes/obico.py

@@ -2,14 +2,18 @@
 
 import logging
 
-from fastapi import APIRouter, HTTPException, Response
+from fastapi import APIRouter, Depends, HTTPException, Response
 from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes.settings import get_setting
 from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.user import User
 from backend.app.services.obico_detection import obico_detection_service, pop_frame
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER, same_value
 
 logger = logging.getLogger(__name__)
 
@@ -18,7 +22,8 @@ router = APIRouter(prefix="/obico", tags=["obico"])
 
 class TestConnectionRequest(BaseModel):
     url: str
-    # Omitted entirely = test with the saved token; "" = test with no token.
+    # Omitted entirely or the placeholder = test with the saved token (against
+    # the saved URL only); "" = test with no token.
     token: str | None = None
 
 
@@ -78,16 +83,18 @@ async def get_printer_status(
 @router.post("/test-connection")
 async def test_connection(
     req: TestConnectionRequest,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
     if not req.url:
         return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
     token = req.token
-    if token is None:
-        # Field omitted entirely — test what the service actually uses.
-        settings = await obico_detection_service._load_settings()
-        token = settings.get("ml_token") or ""
+    if token is None or token == SECRET_PLACEHOLDER:
+        # The saved token, which only goes to the saved URL.
+        if not same_value(req.url, await get_setting(db, "obico_ml_url")):
+            raise HTTPException(status_code=400, detail="Enter the token again: the URL changed")
+        token = await get_setting(db, "obico_ml_token") or ""
     return await obico_detection_service.test_connection(req.url, token)
 
 

+ 1 - 1
backend/app/api/routes/orca_cloud.py

@@ -679,7 +679,7 @@ async def get_profile(
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
     """Fetch a single profile's full content, shaped like
-    ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
+    the frontend's ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
     unchanged. The inner ``setting`` field is the raw slicer-format JSON
     Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
     BambuStudio fork."""

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

@@ -8,10 +8,11 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.services.archive import ArchiveService, resolve_display_stem
 
@@ -91,7 +92,7 @@ async def _augment_with_display_name(
 @router.get("/", response_model=list[PendingUploadResponse])
 async def list_pending_uploads(
     db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -109,7 +110,7 @@ async def list_pending_uploads(
 @router.get("/count")
 async def get_pending_count(
     db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -218,7 +219,7 @@ async def discard_all_pending(
 async def get_pending_upload(
     upload_id: int,
     db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -256,6 +257,11 @@ async def archive_pending_upload(
     if not file_path.exists():
         raise HTTPException(status_code=404, detail="Upload file not found on disk")
 
+    # The project must exist before anything is archived; 0 means none.
+    project_id = (request.project_id or None) if request else None
+    if project_id is not None and await db.get(Project, project_id) is None:
+        raise HTTPException(status_code=400, detail="Project not found")
+
     # Archive the file
     from backend.app.api.routes.settings import get_setting
 
@@ -281,8 +287,8 @@ async def archive_pending_upload(
             archive.tags = request.tags
         if request.notes:
             archive.notes = request.notes
-        if request.project_id:
-            archive.project_id = request.project_id
+        if project_id is not None:
+            archive.project_id = project_id
 
     # Update pending record
     pending.status = "archived"
@@ -291,7 +297,7 @@ async def archive_pending_upload(
     if request:
         pending.tags = request.tags
         pending.notes = request.notes
-        pending.project_id = request.project_id
+        pending.project_id = project_id
 
     await db.commit()
 

+ 67 - 19
backend/app/api/routes/pipeline_runs.py

@@ -40,6 +40,7 @@ from backend.app.core.auth import (
     RequestActor,
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
+    is_auth_enabled,
 )
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
@@ -69,6 +70,8 @@ from backend.app.services.pipeline_eligibility import (
     check_pipeline_eligibility,
 )
 from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
+from backend.app.services.print_defaults import default_print_options
+from backend.app.services.queue_announcements import announce_queue_jobs_added
 
 logger = logging.getLogger(__name__)
 
@@ -187,7 +190,10 @@ def _compute_job_status(
     if persisted in ("failed", "cancelled", "completed"):
         return persisted
     if queue_entry is None:
-        return persisted
+        # Dispatched, but its queue entry is gone without a final status stored
+        # (deleted before jobs stored it, or removed by the database itself):
+        # printed or not cannot be told, so neither failed nor cancelled.
+        return "unknown" if persisted in ("queued", "printing") else persisted
     qs = queue_entry.status
     if qs == "completed":
         return "completed"
@@ -220,19 +226,21 @@ def _roll_up_run_status(
         return persisted
 
     completed = sum(1 for s in job_statuses if s == "completed")
+    # A copy whose outcome is unknown ended without failing anything to retry.
+    unknown = sum(1 for s in job_statuses if s == "unknown")
     failed = sum(1 for s in job_statuses if s == "failed")
     cancelled = sum(1 for s in job_statuses if s == "cancelled")
     in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
     total = len(job_statuses)
 
-    if completed == total:
+    if completed + unknown == total:
         return "completed"
     if in_flight > 0:
         return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
     # All copies are in terminal states.
     if failed == 0 and cancelled == total:
         return "cancelled"
-    if completed > 0 and (failed > 0 or cancelled > 0):
+    if completed + unknown > 0 and (failed > 0 or cancelled > 0):
         return "partial_failure"
     if failed > 0:
         return "failed"
@@ -293,16 +301,6 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
             printer_name = p.name if p else None
 
         live_job_status = _compute_job_status(job.status, queue_entry)
-        # If the job WAS dispatched (had a queue_entry_id) but the entry has
-        # since been deleted from the queue page, the user's intent was
-        # cancellation. Otherwise the run would stay forever showing as
-        # ``queued`` because the persisted job.status hasn't been updated.
-        if (
-            job.queue_entry_id is not None
-            and queue_entry is None
-            and live_job_status not in ("completed", "failed", "cancelled")
-        ):
-            live_job_status = "cancelled"
         job_live_statuses.append(live_job_status)
         job_responses.append(
             PipelineJobResponse(
@@ -356,8 +354,12 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
 async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
     """Broadcast a ``pipeline_run_updated`` event with the full materialised
     run. Per-user routing via ``broadcast_to_user`` falls back to a global
-    broadcast when ``created_by`` is None (auth-disabled installs)."""
+    broadcast when ``created_by`` is None on auth-disabled installs."""
     try:
+        # Without a user, broadcast_to_user would send it to everyone. With auth
+        # on, a run whose user was deleted is nobody's to watch (the runs page polls).
+        if run.created_by is None and await is_auth_enabled(db):
+            return
         payload = await _materialise_run(db, run)
         await ws_manager.broadcast_to_user(
             run.created_by,
@@ -410,6 +412,11 @@ async def _resolve_source(
     arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
     can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
     arc = _ensure_archive_visible(arc, user, can_read_all, printer_scope)
+    # Running a pipeline on an archive prints it again: the same rules as
+    # queueing it, including the right to reprint it.
+    from backend.app.api.routes.print_queue import _assert_can_queue_archive
+
+    _assert_can_queue_archive(arc, user, printer_scope)
     rel = arc.source_3mf_path or arc.file_path
     if not rel:
         raise HTTPException(400, "Archive has no source file to slice")
@@ -443,7 +450,7 @@ async def _pick_assignments(
         (
             await db.execute(
                 select(Printer)
-                .where(Printer.model == pipeline.target_model_class)
+                .where(func.lower(Printer.model) == (pipeline.target_model_class or "").lower())
                 .where(Printer.is_active.is_(True))
                 .order_by(Printer.id)
             )
@@ -468,8 +475,9 @@ async def _pick_assignments(
         # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
         return [(matching[i % len(matching)].id, None) for i in range(copies)]
     # max_parallel — leave printer_id=None, set target_model so the scheduler
-    # picks any free X1C / P1S / … for each item independently.
-    return [(None, pipeline.target_model_class)] * copies
+    # picks any free X1C / P1S / … for each item independently. In the
+    # printers' own spelling: the class is matched without regard to case.
+    return [(None, matching[0].model)] * copies
 
 
 def _make_orchestration_callable(
@@ -587,17 +595,32 @@ def _make_orchestration_callable(
 
             # No per-job ask-for-outcome toggle on a pipeline run either (#1898).
             confirm_outcome = await confirm_outcome_for_new_queue_item(session)
+            # Nor print options: the install's defaults.
+            print_options = await default_print_options(session)
 
             for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
                 queue_item = PrintQueueItem(
                     printer_id=printer_id,
                     target_model=target_model,
                     library_file_id=slice_response.library_file_id,
-                    created_by_id=creator_user_id,
+                    # From the run as refreshed after the slice, not the id the
+                    # request started with: a user deleted meanwhile is gone
+                    # from it, and SQLite may have given the id to someone else.
+                    created_by_id=run.created_by,
                     status="pending",
                     confirm_outcome=confirm_outcome,
                     # The copies wait for review like any other job of theirs (#1620)
-                    manual_start=review_required,
+                    # A restricted user deleted while it sliced: as delete_user
+                    # stages their ownerless "any model" jobs, or these would run
+                    # on every printer of the model, not only on theirs (#1727).
+                    manual_start=review_required
+                    or (
+                        run.created_by is None
+                        and creator_user_id is not None
+                        and printer_id is None
+                        and not printer_scope.is_unrestricted
+                    ),
+                    **print_options,
                 )
                 session.add(queue_item)
                 await session.flush()
@@ -629,6 +652,7 @@ def _make_orchestration_callable(
                         ).scalar_one_or_none()
                         if qe is not None and qe.status in ("pending", "queued"):
                             qe.status = "cancelled"
+                            qe.completed_at = datetime.now(timezone.utc)
                     if job.status not in ("completed", "failed", "cancelled"):
                         job.status = "cancelled"
                         job.completed_at = datetime.now(timezone.utc)
@@ -640,6 +664,7 @@ def _make_orchestration_callable(
                 job.status = "queued"
             run.status = "dispatching"
             await session.commit()
+            await announce_queue_jobs_added(session, [job.queue_entry_id for job in jobs if job.queue_entry_id])
             await _publish_run_event(session, run)
 
             return slice_response.model_dump()
@@ -974,19 +999,42 @@ async def cancel_run(
     if not run.error_message:
         run.error_message = "Cancelled by user"
 
+    from backend.app.services.finance_budget import release_budget_reservation
+
+    cancelled_entries: list[int] = []
     job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
     for job in job_rows:
+        queue_entry = None
         if job.queue_entry_id:
             queue_entry = (
                 await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
             ).scalar_one_or_none()
             if queue_entry is not None and queue_entry.status in ("pending", "queued"):
                 queue_entry.status = "cancelled"
+                queue_entry.completed_at = datetime.now(timezone.utc)
+                # As a cancel from the queue does: its budget hold is freed
+                await release_budget_reservation(
+                    db, source_type="print_queue", source_id=queue_entry.id, status="released"
+                )
+                cancelled_entries.append(queue_entry.id)
+        if job.status in ("queued", "printing") and queue_entry is None:
+            # Dispatched, its entry gone: whether it printed is unknown, and
+            # calling it cancelled would make retry print it again.
+            continue
         if job.status not in ("completed", "failed", "cancelled"):
             job.status = "cancelled"
             job.completed_at = datetime.now(timezone.utc)
 
     await db.commit()
+    if cancelled_entries:
+        from backend.app.services.print_batch import refresh_batches_after
+        from backend.app.services.print_scheduler import scheduler
+
+        await refresh_batches_after(cancelled_entries)
+        # A preheating entry's dispatch is parked in a sleep and cannot see the
+        # status written above (#2727): told, it stops heating the printer.
+        for entry_id in cancelled_entries:
+            scheduler.notify_dispatch_cancelled(entry_id)
     await db.refresh(run)
     await _publish_run_event(db, run)
     return await _materialise_run(db, run)

+ 7 - 4
backend/app/api/routes/print_log.py

@@ -7,6 +7,7 @@ from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    ApiKeyActor,
     MediaOrRequestPrinterScope,
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
@@ -64,7 +65,7 @@ async def get_print_log(
     sort_by: str = Query(default="date"),
     sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -145,7 +146,7 @@ async def get_print_log(
 async def get_print_log_thumbnail(
     entry_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -213,7 +214,7 @@ async def clear_print_log(
 async def delete_print_log_entry(
     entry_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_OWN,
@@ -270,6 +271,8 @@ _FAILURE_REASON_KEYS = frozenset(
         # recognise, so leaving it out would delete the classification on the
         # next save of such an archive.
         "noStatusUpdate",
+        # A print the printer kill switch stopped (written at its completion).
+        "killSwitch",
         "other",
     }
 )
@@ -283,7 +286,7 @@ async def update_print_log_entry(
     entry_id: int,
     update: PrintLogEntryUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,

+ 353 - 122
backend/app/api/routes/print_queue.py

@@ -2,6 +2,7 @@
 
 import json
 import logging
+import re
 import zipfile
 from datetime import datetime, timezone
 from pathlib import Path
@@ -28,6 +29,7 @@ from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope, ensure_model_target_allowed
+from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
@@ -55,15 +57,17 @@ from backend.app.schemas.print_queue import (
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.finance_budget import release_budget_reservation, validate_print_budget
-from backend.app.services.notification_service import notification_service
 from backend.app.services.print_batch import (
     BatchDispatchError,
     dispatch_remaining,
     load_progress,
     refresh_batch_status,
     refresh_batch_status_for_item,
+    refresh_batches_after,
 )
 from backend.app.services.print_cost_estimate import estimate_queue_source_cost
+from backend.app.services.queue_announcements import announce_queue_jobs_added
+from backend.app.services.queue_item_printer import PRINTER_RESOLVED_DEFAULTS, drop_printer_resolved
 from backend.app.services.queue_position import lock_queue_positions, max_queue_position
 from backend.app.utils.printer_models import (
     is_gcode_compatible,
@@ -169,15 +173,19 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
 _extract_print_time_from_3mf = extract_print_time_from_3mf
 
 
-def _assert_can_queue_archive(archive: PrintArchive, current_user: User | ApiKeyActor | None) -> None:
+def _assert_can_queue_archive(
+    archive: PrintArchive, current_user: User | ApiKeyActor | None, printer_scope: PrinterScope
+) -> None:
     """Gate turning *archive* into a print. Raises rather than returning a verdict.
 
     Shared by every route that creates queue items from an archive, so a new
     one can't quietly become a weaker door to the same action than
     ``POST /queue/`` is.
 
-    Two separate checks:
+    Three checks:
 
+    * The archive must not be deleted, and its printer must be one the caller
+      may use; otherwise it reads as missing, as the archive routes answer.
     * IDOR fix (maziggy/bambuddy-security #2): without this, a caller with
       QUEUE_CREATE could queue any user's archive even without ARCHIVES_READ on
       it — Landon's PoC enumerated this on admin's archives as operator1. Gate
@@ -191,12 +199,17 @@ def _assert_can_queue_archive(archive: PrintArchive, current_user: User | ApiKey
       allows any archive, REPRINT_OWN allows own only, ownerless archives
       require REPRINT_ALL (fail-closed).
     """
-    # None is auth off. An API key arrives as its RequestActor, never None.
+    # A deleted archive, or one from a printer the caller can't use, reads as
+    # missing, like the archive routes answer for it.
+    if archive.deleted_at is not None or not printer_scope.allows(archive.printer_id):
+        raise HTTPException(404, "Archive not found")
+    # None is auth off (for an API key too).
     if current_user is None:
         return
-    if not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value) and archive.created_by_id != current_user.id:
-        raise HTTPException(404, "Archive not found")
     owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
+    # An ownerless archive is no one's own, also not a caller without an id.
+    if not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value) and not owns_archive:
+        raise HTTPException(404, "Archive not found")
     has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
         owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
     )
@@ -212,10 +225,9 @@ def _assert_can_queue_library_file(library_file: LibraryFile, current_user: User
     # None is auth off. An API key arrives as its RequestActor, never None.
     if current_user is None:
         return
-    if (
-        not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
-        and library_file.created_by_id != current_user.id
-    ):
+    # An ownerless file is no one's own, also not a caller without an id.
+    owns_file = library_file.created_by_id is not None and library_file.created_by_id == current_user.id
+    if not current_user.has_permission(Permission.LIBRARY_READ_ALL.value) and not owns_file:
         raise HTTPException(404, "Library file not found")
 
 
@@ -279,7 +291,7 @@ async def _is_orders_last_source(db: AsyncSession, item: PrintQueueItem) -> bool
 
 
 async def _assert_can_dispatch_batch_sources(
-    db: AsyncSession, batch_id: int, current_user: User | ApiKeyActor | None
+    db: AsyncSession, batch_id: int, current_user: User | ApiKeyActor | None, printer_scope: PrinterScope
 ) -> None:
     """Apply the ``POST /queue/`` source-file gates to everything a dispatch would print.
 
@@ -333,7 +345,7 @@ async def _assert_can_dispatch_batch_sources(
         archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
         # A deleted source can't be printed; dispatch will fail on it anyway.
         if archive is not None:
-            _assert_can_queue_archive(archive, current_user)
+            _assert_can_queue_archive(archive, current_user, printer_scope)
 
     for library_file_id in library_file_ids:
         library_file = (
@@ -605,6 +617,65 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     return response
 
 
+def saved_slicer_ams_mapping(archive: PrintArchive | None, printer_id: int | None) -> str | None:
+    """The AMS slots the slicer picked when *archive* printed, as JSON, for a
+    reprint on that same printer; None otherwise.
+
+    Slot numbers mean something only on the printer they were picked on, so a
+    model-based job (no printer) or one for another printer gets none.
+    """
+    if archive is None or not archive.extra_data or printer_id is None:
+        return None
+    saved = archive.extra_data.get("slicer_ams_mapping")
+    if (
+        isinstance(saved, dict)
+        and saved.get("printer_id") == printer_id
+        and isinstance(saved.get("mapping"), list)
+        and saved["mapping"]
+    ):
+        return json.dumps(saved["mapping"])
+    return None
+
+
+def printed_plate_of_multi_plate_file(file_path: Path | None) -> int | None:
+    """The plate a dispatch of *file_path* prints when no plate is named, for
+    a file with more than one sliced plate; None otherwise.
+
+    Stored on a queue job that names none, it makes every later step (the
+    filament a printer must have, the AMS mapping, the unmatched-filament
+    check, charging) read the plate that prints rather than all of them.
+    What prints does not change: the dispatch resolves the same plate.
+    """
+    if file_path is None or not file_path.name.lower().endswith(".3mf") or not file_path.exists():
+        return None
+    try:
+        with zipfile.ZipFile(file_path) as zf:
+            plates = [n for n in zf.namelist() if re.fullmatch(r"Metadata/plate_\d+\.gcode", n)]
+    except (OSError, zipfile.BadZipFile):
+        return None
+    if len(plates) < 2:
+        return None
+    from backend.app.services.print_scheduler import _effective_plate_id
+
+    return _effective_plate_id(None, file_path)
+
+
+async def _active_printer_model(db: AsyncSession, model: str) -> str | None:
+    """The model as an active printer spells it, matched without regard to case
+    as the scheduler matches it ("x1c" finds the X1Cs); None when no active
+    printer is of that model. Stored in that spelling, a job compares equal
+    wherever its model is compared exactly."""
+    return (
+        await db.execute(
+            select(Printer.model)
+            .where(func.lower(Printer.model) == model.lower())
+            .where(Printer.is_active == True)  # noqa: E712
+            .order_by(Printer.id)
+            .limit(1)
+        )
+    ).scalar_one_or_none()
+
+
 @router.get("/", response_model=list[PrintQueueItemResponse])
 async def list_queue(
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
@@ -613,7 +684,7 @@ async def list_queue(
         None, description="Filter by target model (also includes model-based items when combined with printer_id)"
     ),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -678,7 +749,9 @@ async def list_queue(
     elif target_model:
         query = query.where(func.lower(PrintQueueItem.target_model) == target_model.lower())
     if status:
-        query = query.where(PrintQueueItem.status == status)
+        # Several statuses, comma-separated (?status=pending,printing).
+        statuses = [part.strip() for part in status.split(",") if part.strip()]
+        query = query.where(PrintQueueItem.status.in_(statuses))
 
     result = await db.execute(query)
     items = result.scalars().all()
@@ -719,7 +792,7 @@ async def _resolve_queue_variants(
         if not library_file or (
             current_user
             and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
-            and library_file.created_by_id != current_user.id
+            and (library_file.created_by_id is None or library_file.created_by_id != current_user.id)
         ):
             raise HTTPException(404, f"Library file not found: {spec.library_file_id}")
 
@@ -747,24 +820,17 @@ async def _resolve_queue_variants(
                 f"{library_file.filename} was sliced for {sliced_for} and cannot be dispatched to {model} printers",
             )
 
-        if model in seen_models:
+        # The printers' own spelling, matched without regard to case
+        has_printer = await _active_printer_model(db, model)
+        model = has_printer or model
+        if model.lower() in seen_models:
             raise HTTPException(
                 400,
-                f"{library_file.filename} and {seen_models[model]} are both for {model} — "
+                f"{library_file.filename} and {seen_models[model.lower()]} are both for {model} — "
                 "variants must target different printers",
             )
-        seen_models[model] = library_file.filename
-
-        has_printer = (
-            (
-                await db.execute(
-                    select(Printer).where(Printer.model == model).where(Printer.is_active == True)  # noqa: E712
-                )
-            )
-            .scalars()
-            .first()
-        )
-        any_active_printer = any_active_printer or bool(has_printer)
+        seen_models[model.lower()] = library_file.filename
+        any_active_printer = any_active_printer or has_printer is not None
 
         resolved.append((spec, library_file, model))
 
@@ -836,6 +902,9 @@ async def add_to_queue(
     actor: User | ApiKeyActor | None = RequestActor,
 ):
     """Add an item to the print queue."""
+    # 0 is no file, as on the checks below; stored, it would point at nothing.
+    data.archive_id = data.archive_id or None
+    data.library_file_id = data.library_file_id or None
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E").
     # normalize_model_name resolves internal codes first: the previous
     # `normalize_printer_model(x) or normalize_printer_model_id(x)` chain never
@@ -891,28 +960,29 @@ async def add_to_queue(
     # target_model there is just the first candidate, and _resolve_queue_variants
     # has already required that *some* candidate has a printer.
     if target_model_norm and not data.variants:
-        result = await db.execute(
-            select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True)  # noqa: E712
-        )
-        if not result.scalars().first():
+        active_model = await _active_printer_model(db, target_model_norm)
+        if active_model is None:
             raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+        target_model_norm = active_model
 
     # Validate archive exists (if provided) and get it for filament extraction
     archive = None
     if data.archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
         archive = result.scalar_one_or_none()
+        # Missing answers like hidden (404 from the gate below).
         if not archive:
-            raise HTTPException(400, "Archive not found")
-        _assert_can_queue_archive(archive, actor)
+            raise HTTPException(404, "Archive not found")
+        _assert_can_queue_archive(archive, actor, printer_scope)
 
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
     if data.library_file_id:
         result = await db.execute(LibraryFile.active().where(LibraryFile.id == data.library_file_id))
         library_file = result.scalar_one_or_none()
+        # Missing answers like hidden (404 from the gate below).
         if not library_file:
-            raise HTTPException(400, "Library file not found")
+            raise HTTPException(404, "Library file not found")
         _assert_can_queue_library_file(library_file, actor)
         # Bambu SD card is FAT32/exFAT — illegal filename chars would 553 at
         # FTP upload time (#1540). Reject at queue time so the user gets the
@@ -949,9 +1019,10 @@ async def add_to_queue(
     elif library_file:
         lib_path = Path(library_file.file_path)
         file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+    plate_id = data.plate_id or printed_plate_of_multi_plate_file(file_path)
     if target_model_norm:
         if file_path and file_path.exists():
-            filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
+            filament_types = _extract_filament_types_from_3mf(file_path, plate_id)
             if filament_types:
                 required_filament_types = json.dumps(filament_types)
                 logger.info("Extracted filament types for model-based queue: %s", filament_types)
@@ -990,8 +1061,12 @@ async def add_to_queue(
         existing_batch = result.scalar_one_or_none()
         if not existing_batch:
             raise HTTPException(404, "Batch not found")
-        if existing_batch.status != "active":
+        await _ensure_batch_in_scope(db, existing_batch.id, printer_scope)
+        # A grouping whose jobs all failed takes new ones, and is open again.
+        if existing_batch.status not in ("active", "failed"):
             raise HTTPException(400, "Cannot add items to a non-active batch")
+        if existing_batch.status == "failed":
+            existing_batch.status = "active"
         if (
             actor is not None
             and existing_batch.created_by_id is not None
@@ -1106,7 +1181,7 @@ async def add_to_queue(
             db,
             archive=archive,
             library_file=library_file,
-            plate_id=data.plate_id,
+            plate_id=plate_id,
             ams_mapping=data.ams_mapping,
             printer_id=data.printer_id,
         )
@@ -1152,21 +1227,8 @@ async def add_to_queue(
     wants_live_color_match = any(
         isinstance(o, dict) and o.get("force_color_match") for o in (data.filament_overrides or [])
     )
-    if (
-        ams_mapping_json is None
-        and not wants_live_color_match
-        and archive
-        and archive.extra_data
-        and data.printer_id is not None
-    ):
-        saved = archive.extra_data.get("slicer_ams_mapping")
-        if (
-            isinstance(saved, dict)
-            and saved.get("printer_id") == data.printer_id
-            and isinstance(saved.get("mapping"), list)
-            and saved["mapping"]
-        ):
-            ams_mapping_json = json.dumps(saved["mapping"])
+    if ams_mapping_json is None and not wants_live_color_match:
+        ams_mapping_json = saved_slicer_ams_mapping(archive, data.printer_id)
     items = []
     for i in range(quantity):
         item = PrintQueueItem(
@@ -1188,7 +1250,7 @@ async def add_to_queue(
             skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
             nozzle_rack_choice=nozzle_rack_choice_json,
-            plate_id=data.plate_id,
+            plate_id=plate_id,
             bed_levelling=data.bed_levelling,
             flow_cali=data.flow_cali,
             vibration_cali=data.vibration_cali,
@@ -1239,52 +1301,30 @@ async def add_to_queue(
     qty_desc = f" (×{quantity})" if quantity > 1 else ""
     logger.info("Added %s to queue for %s%s", source_name, target_desc, qty_desc)
 
-    # MQTT relay - publish queue job added
-    try:
-        from backend.app.services.mqtt_relay import mqtt_relay
+    await announce_queue_jobs_added(db, [i.id for i in items])
 
-        await mqtt_relay.on_queue_job_added(
-            job_id=item.id,
-            filename=item.archive.filename if item.archive else "",
-            printer_id=item.printer_id,
-            printer_name=item.printer.name if item.printer else None,
-        )
-    except Exception:
-        pass  # Don't fail queue add if MQTT fails
+    return _enrich_response(item)
 
-    # Send notification for job added
-    try:
-        job_name = (
-            item.archive.filename
-            if item.archive
-            else item.library_file.filename
-            if item.library_file
-            else f"Job #{item.id}"
-        )
-        job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
-        if quantity > 1:
-            job_name = f"{job_name} ×{quantity}"
-        target = (
-            item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
-        )
-        await notification_service.on_queue_job_added(
-            job_name=job_name,
-            target=target,
-            db=db,
-            printer_id=item.printer_id,
-            printer_name=item.printer.name if item.printer else None,
-        )
-    except Exception:
-        pass  # Don't fail queue add if notification fails
 
-    return _enrich_response(item)
+def _releases_model_job(item: PrintQueueItem, update_data: dict) -> bool:
+    """Whether an edit lets the scheduler dispatch ``item`` as an "any printer
+    of a model" job: it starts a waiting one, or takes the printer off one that
+    keeps its model. The scheduler then picks from its creator's printers,
+    which a limited key may not reach (see ``ensure_model_target_allowed``).
+    """
+    if update_data.get("printer_id", item.printer_id) is not None:
+        return False
+    if not update_data.get("target_model", item.target_model):
+        return False
+    starts = bool(item.manual_start) and "manual_start" in update_data and not update_data["manual_start"]
+    return starts or item.printer_id is not None
 
 
 @router.patch("/bulk", response_model=PrintQueueBulkUpdateResponse)
 async def bulk_update_queue_items(
     data: PrintQueueBulkUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -1316,7 +1356,11 @@ async def bulk_update_queue_items(
             raise HTTPException(400, "Printer not found")
 
     # Fetch all items, minus any bound to a printer the caller can't see (#1727)
-    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
+    result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.id.in_(data.item_ids))
+        .options(selectinload(PrintQueueItem.variants))
+    )
     items = [item for item in result.scalars().all() if printer_scope.allows(item.printer_id)]
 
     updated_count = 0
@@ -1346,8 +1390,20 @@ async def bulk_update_queue_items(
         ):
             skipped_count += 1
             continue
+        if _releases_model_job(item, update_data):
+            try:
+                ensure_model_target_allowed(user, printer_scope)
+            except HTTPException:
+                skipped_count += 1
+                continue
 
         item_update_data = update_data.copy()
+        rebinds = "printer_id" in item_update_data and item_update_data["printer_id"] != item.printer_id
+        # A job with printer alternatives picks its own printer (#671), as the
+        # single edit refuses to override.
+        if rebinds and item.variants:
+            skipped_count += 1
+            continue
         if validates_billing_fields:
             trusted_estimated_cost = await _trusted_item_estimated_cost(
                 db,
@@ -1365,6 +1421,25 @@ async def bulk_update_queue_items(
                 exclude_queue_item_id=item.id,
             )
 
+        # Re-check right before changing it, as the single edit does (#2615):
+        # the validations above are awaited, and a scheduler worker may have
+        # claimed the row (or it stopped being pending) meanwhile. Nothing on
+        # this row has been changed yet, so skipping it leaves it as it is.
+        current = (
+            await db.execute(
+                select(PrintQueueItem.status, PrintQueueItem.dispatching_at).where(PrintQueueItem.id == item.id)
+            )
+        ).one_or_none()
+        if current is None or current.status != "pending" or current.dispatching_at is not None:
+            skipped_count += 1
+            continue
+
+        if rebinds:
+            # Now bound to this printer: not to a model, and nothing worked
+            # out for the old printer carries over.
+            item.target_model = None
+            item.target_location = None
+            drop_printer_resolved(item)
         for field, value in item_update_data.items():
             setattr(item, field, value)
         updated_count += 1
@@ -1492,6 +1567,22 @@ async def create_batch(
 
     plate_targets = _validate_plate_targets(data.plates)
     await _validate_batch_project(db, data.project_id, actor)
+    # The file the batch prints: 0 is none, any other id passes the same source
+    # checks as POST /queue/, and a missing one answers like a hidden one.
+    data.archive_id = data.archive_id or None
+    data.library_file_id = data.library_file_id or None
+    if data.archive_id is not None:
+        batch_archive = await db.get(PrintArchive, data.archive_id)
+        if batch_archive is None:
+            raise HTTPException(404, "Archive not found")
+        _assert_can_queue_archive(batch_archive, actor, printer_scope)
+    if data.library_file_id is not None:
+        batch_file = (
+            await db.execute(LibraryFile.active().where(LibraryFile.id == data.library_file_id))
+        ).scalar_one_or_none()
+        if batch_file is None:
+            raise HTTPException(404, "Library file not found")
+        _assert_can_queue_library_file(batch_file, actor)
     if data.external_source is not None:
         existing = await db.execute(
             select(PrintBatch.id).where(
@@ -1574,6 +1665,7 @@ async def update_batch(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
     actor: User | ApiKeyActor | None = RequestActor,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Edit an order's header or its per-plate targets while it runs (#342).
 
@@ -1583,6 +1675,7 @@ async def update_batch(
     explicit action, because silently deleting queued work on a number change
     would be a nasty surprise.
     """
+    await _ensure_batch_in_scope(db, batch_id, printer_scope)
     batch = await _load_batch_for_write(db, batch_id, actor, Permission.QUEUE_UPDATE_ALL)
 
     plate_targets = _validate_plate_targets(data.plates)
@@ -1638,6 +1731,35 @@ async def update_batch(
     return await _build_batch_response(db, batch)
 
 
+async def _validate_copies_budget(
+    db: AsyncSession, copies: list[PrintQueueItem], actor: User | ApiKeyActor | None
+) -> None:
+    """An order's copies are billed to their template's cost centre: the
+    dispatcher must be allowed to print with it, and its budget must hold
+    every copy, as POST /queue/ checks for a quantity. Only with billing on;
+    then a copy without a cost centre is refused as POST /queue/ refuses it."""
+    by_center: dict[int | None, list[PrintQueueItem]] = {}
+    for copy in copies:
+        by_center.setdefault(copy.cost_center_id, []).append(copy)
+    for cost_center_id, center_copies in by_center.items():
+        estimates = [
+            await _trusted_item_estimated_cost(
+                db, copy, printer_id=copy.printer_id, plate_id=copy.plate_id, ams_mapping=copy.ams_mapping
+            )
+            for copy in center_copies
+        ]
+        await validate_print_budget(
+            db,
+            cost_center_id=cost_center_id,
+            estimated_cost=None if any(e is None for e in estimates) else sum(e for e in estimates if e is not None),
+            current_user=actor,
+        )
+        # Set after the check: a pending copy's estimate counts as held, and
+        # the copies must not be counted against themselves.
+        for copy, estimate in zip(center_copies, estimates, strict=True):
+            copy.estimated_cost = estimate
+
+
 @router.post("/batches/{batch_id}/dispatch", response_model=PrintBatchResponse)
 async def dispatch_batch(
     batch_id: int,
@@ -1655,12 +1777,13 @@ async def dispatch_batch(
     overrides and print options the user already chose — and the validation
     those went through at creation time.
     """
+    await _ensure_batch_in_scope(db, batch_id, printer_scope)
     batch = await _load_batch_for_write(db, batch_id, actor, Permission.QUEUE_UPDATE_ALL)
     if batch.status == "cancelled":
         raise HTTPException(400, "Cannot dispatch a cancelled batch")
 
     # Dispatch starts prints, so it must not be a weaker door than POST /queue/.
-    await _assert_can_dispatch_batch_sources(db, batch.id, actor)
+    await _assert_can_dispatch_batch_sources(db, batch.id, actor, printer_scope)
 
     try:
         created = await dispatch_remaining(
@@ -1680,6 +1803,7 @@ async def dispatch_batch(
             printer_scope.ensure(item.printer_id)
             if item.printer_id is None:
                 ensure_model_target_allowed(current_user, printer_scope)
+        await _validate_copies_budget(db, created, actor)
     except HTTPException:
         await db.rollback()
         raise
@@ -1690,6 +1814,7 @@ async def dispatch_batch(
             item.manual_start = True
 
     await db.commit()
+    await announce_queue_jobs_added(db, [item.id for item in created])
     await db.refresh(batch)
 
     logger.info("Batch %s dispatched %d item(s)", batch.id, len(created))
@@ -1750,12 +1875,13 @@ async def list_batches(
     external_source: str | None = Query(None, description="Filter by the integration that created the batch"),
     external_ref: str | None = Query(None, description="Filter by the external record the batch fulfils"),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List print batches with progress stats.
 
@@ -1782,6 +1908,18 @@ async def list_batches(
         query = query.where(PrintBatch.external_ref == external_ref)
     if current_user is not None and not can_read_all:
         query = query.where(PrintBatch.created_by_id == current_user.id)
+    if printer_scope.printer_ids is not None:
+        # A batch with a job on a printer the caller can't see is missing for
+        # them, as when cancelling or ungrouping it.
+        query = query.where(
+            ~select(PrintQueueItem.id)
+            .where(
+                PrintQueueItem.batch_id == PrintBatch.id,
+                PrintQueueItem.printer_id.is_not(None),
+                PrintQueueItem.printer_id.not_in(printer_scope.printer_ids),
+            )
+            .exists()
+        )
     result = await db.execute(query)
     batches = result.scalars().all()
 
@@ -1799,12 +1937,13 @@ async def list_batches(
 async def get_batch(
     batch_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get a print batch with progress stats."""
     current_user, can_read_all = auth_result
@@ -1818,6 +1957,7 @@ async def get_batch(
         and (batch.created_by_id is None or batch.created_by_id != current_user.id)
     ):
         raise HTTPException(404, "Batch not found")
+    await _ensure_batch_in_scope(db, batch_id, printer_scope)
     return await _build_batch_response(db, batch)
 
 
@@ -1844,6 +1984,7 @@ async def cancel_batch(
     cancelled_ids: list[int] = []
     for item in pending_items:
         item.status = "cancelled"
+        item.completed_at = datetime.now(timezone.utc)
         await release_budget_reservation(
             db,
             source_type="print_queue",
@@ -1944,7 +2085,7 @@ async def _build_batch_response(
 async def get_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -1984,7 +2125,7 @@ async def update_queue_item(
     item_id: int,
     data: PrintQueueItemUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -2035,6 +2176,8 @@ async def update_queue_item(
         and not may_start_queue_item(user, can_modify_all, item.created_by_id)
     ):
         raise HTTPException(403, _AWAITING_REVIEW)
+    if _releases_model_job(item, update_data):
+        ensure_model_target_allowed(user, printer_scope)
 
     # Normalize target_model if being updated (see add_to_queue for why the
     # code map has to run first).
@@ -2075,11 +2218,10 @@ async def update_queue_item(
 
     # Validate target_model has active printers
     if "target_model" in update_data and update_data["target_model"]:
-        result = await db.execute(
-            select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True)  # noqa: E712
-        )
-        if not result.scalars().first():
+        active_model = await _active_printer_model(db, update_data["target_model"])
+        if active_model is None:
             raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
+        update_data["target_model"] = active_model
 
         # Cross-model safety gate (#2578) — same check as the create route, so
         # a mismatched target can't be introduced by editing either.
@@ -2105,6 +2247,11 @@ async def update_queue_item(
         update_data["ams_mapping"] = None
         if item.printer_id and "skip_filament_check" not in update_data:
             update_data["skip_filament_check"] = False
+    # Likewise a job moved off its printer to another one: what the dialog
+    # sends for the new printer is kept, the rest goes with the old one.
+    if item.printer_id is not None and new_printer_id != item.printer_id:
+        for field, default in PRINTER_RESOLVED_DEFAULTS.items():
+            update_data.setdefault(field, default)
 
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
@@ -2178,7 +2325,7 @@ async def update_queue_item(
 async def delete_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_DELETE_ALL,
             Permission.QUEUE_DELETE_OWN,
@@ -2218,8 +2365,10 @@ async def delete_queue_item(
         source_id=item.id,
         status="released",
     )
+    deleted_batch_id = item.batch_id
     if keep_as_cancelled:
         item.status = "cancelled"
+        item.completed_at = datetime.now(timezone.utc)
         await db.flush()
         # The order may have been sitting on "completed" if this run's target
         # was met by it; cancelling reopens it.
@@ -2227,6 +2376,14 @@ async def delete_queue_item(
     else:
         await db.delete(item)
     await db.commit()
+    if not keep_as_cancelled and deleted_batch_id is not None:
+        # A grouping whose last open job was deleted may be finished now.
+        try:
+            batch = await db.get(PrintBatch, deleted_batch_id)
+            if batch is not None and await refresh_batch_status(db, batch):
+                await db.commit()
+        except Exception as e:
+            logger.warning("Failed to refresh batch %s after a delete: %s", deleted_batch_id, e)
 
     # Stop an in-flight preheat for this item: the dispatch coroutine is
     # parked in a sleep and cannot see the status we just wrote (#2727).
@@ -2323,11 +2480,51 @@ async def resume_queue_after_failure(
     return {"acknowledged": len(to_ack), "restored": len(to_restore)}
 
 
+@router.post("/model/{model}/resume")
+async def resume_model_queue_after_failure(
+    model: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Clear the previous-success gate on every printer of a model the caller may use.
+
+    A job for any printer of a model waits, rather than being skipped, when the
+    last print failed on every free one; this is its Resume. Same effect per
+    printer as :func:`resume_queue_after_failure`, which has no skipped jobs to
+    restore here.
+    """
+    from backend.app.services.print_scheduler import scheduler
+
+    printers = await scheduler._printers_for_model(db, model, None, printer_scope)
+    printer_ids = [p.id for p in printers]
+    acknowledged = 0
+    if printer_ids:
+        result = await db.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.printer_id.in_(printer_ids))
+            .where(PrintQueueItem.status.in_(["failed", "aborted"]))
+            .where(PrintQueueItem.gate_acknowledged == False)  # noqa: E712
+        )
+        for failed_item in result.scalars().all():
+            failed_item.gate_acknowledged = True
+            acknowledged += 1
+        await db.commit()
+
+    logger.info(
+        "Resume after failure for model %s: acknowledged %d failure(s) on %d printer(s)",
+        model,
+        acknowledged,
+        len(printer_ids),
+    )
+    return {"acknowledged": acknowledged, "printers": len(printer_ids)}
+
+
 @router.post("/{item_id}/cancel")
 async def cancel_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -2360,6 +2557,7 @@ async def cancel_queue_item(
         status="released",
     )
     await db.commit()
+    await refresh_batches_after([item.id])
 
     # Stop an in-flight preheat for this item: the dispatch coroutine is
     # parked in a sleep and cannot see the status we just wrote (#2727).
@@ -2375,7 +2573,7 @@ async def cancel_queue_item(
 async def stop_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -2391,8 +2589,6 @@ async def stop_queue_item(
     holding only _OWN saw the Stop button in the queue UI but got 403 on click.
     """
 
-    from backend.app.services.printer_manager import printer_manager
-
     user, can_modify_all = auth_result
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
@@ -2414,26 +2610,23 @@ async def stop_queue_item(
     printer_id = item.printer_id
     auto_off_after = item.auto_off_after
 
-    # Try to send stop command to printer
+    # Send the stop and mark the printer as user-stopped, even when the stop
+    # couldn't be sent: the job is closed below either way, so a "failed" the
+    # printer reports later is the user's cancel. Marked before the first await
+    # so an on_print_complete arriving during the db.commit() yield already
+    # sees the flag (no spurious "print failed" notification).
+    from backend.app.services.print_control import stop_print_by_user
+
     stop_sent = False
     try:
-        stop_sent = printer_manager.stop_print(printer_id)
+        # A printing job always has a printer; without one there is nothing to stop.
+        if printer_id is not None:
+            stop_sent = stop_print_by_user(printer_id, mark_when_unsent=True)
         if not stop_sent:
             logger.warning("stop_print returned False for printer %s - printer may not be connected", printer_id)
     except Exception as e:
         logger.error("Error sending stop command for queue item %s: %s", item_id, e)
 
-    # Mark this printer as user-stopped BEFORE the first await so that if the
-    # MQTT on_print_complete callback fires during the db.commit() yield the flag
-    # is already set and the "failed" status will be correctly overridden to
-    # "cancelled" (preventing a spurious "print failed" notification).
-    try:
-        from backend.app.main import mark_printer_stopped_by_user
-
-        mark_printer_stopped_by_user(printer_id)
-    except Exception as _mark_err:
-        logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err)
-
     # Update queue item status regardless - if printer is off, print is already stopped
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)
@@ -2447,14 +2640,48 @@ async def stop_queue_item(
     # archive 436). Close it out here, mirroring what the MQTT path would have
     # done. Only touch a still-"printing" archive so we never overwrite a real
     # completion that raced in.
+    closed_archive_id: int | None = None
     if not stop_sent and item.archive_id:
         archive = await db.get(PrintArchive, item.archive_id)
         if archive and archive.status == "printing":
             archive.status = "cancelled"
             archive.completed_at = datetime.now(timezone.utc)
-            archive.failure_reason = "Stopped by user (printer was offline)"
+            # The vocabulary key every user cancel stores (#2974); that the
+            # printer was offline is on the queue row's error_message.
+            archive.failure_reason = "userCancelled"
+            # The run's log entry, so the stop counts in the print log and
+            # failure statistics. Without word from the printer, how far it got
+            # is unknown: no grams, cost or wear are entered. Should the printer
+            # report the real end after all, that entry is replaced.
+            from backend.app.services.print_log import write_log_entry
+
+            await write_log_entry(
+                db,
+                status="cancelled",
+                archive_id=archive.id,
+                queue_item_id=item.id,
+                print_name=archive.print_name,
+                printer_name=await db.scalar(select(Printer.name).where(Printer.id == printer_id)),
+                printer_id=printer_id,
+                started_at=archive.started_at,
+                completed_at=archive.completed_at,
+                filament_type=archive.filament_type,
+                filament_color=archive.filament_color,
+                failure_reason=archive.failure_reason,
+                thumbnail_path=archive.thumbnail_path,
+                created_by_id=archive.created_by_id,
+            )
+            closed_archive_id = archive.id
+    if not stop_sent:
+        # No print end will come to charge the run and release what the job
+        # held of its cost centre's budget, so release it here, as a cancel
+        # does. A stop that reached the printer leaves it to the print's end.
+        await release_budget_reservation(db, source_type="print_queue", source_id=item.id, status="released")
 
     await db.commit()
+    await refresh_batches_after([item.id])
+    if closed_archive_id is not None:
+        await ws_manager.send_archive_updated({"id": closed_archive_id, "status": "cancelled"})
 
     logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)
 
@@ -2480,7 +2707,7 @@ async def start_queue_item(
     item_id: int,
     skip_filament_check: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -2532,6 +2759,10 @@ async def start_queue_item(
             raise HTTPException(403, "You can only start your own queue items")
     if not may_start_queue_item(user, can_modify_all, item.created_by_id):
         raise HTTPException(403, _AWAITING_REVIEW)
+    if item.printer_id is None and item.target_model:
+        # The scheduler picks from the creator's printers, which a limited key
+        # may not reach (and claiming an unowned job makes the owner creator).
+        ensure_model_target_allowed(user, printer_scope)
 
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")

+ 0 - 33
backend/app/api/routes/printer_sensor_history.py

@@ -105,36 +105,3 @@ async def get_printer_sensor_history(
         )
 
     return PrinterSensorHistoryResponse(printer_id=printer_id, series=series_out)
-
-
-@router.delete("/{printer_id}")
-async def delete_old_history(
-    printer_id: int,
-    days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
-):
-    """Delete old printer sensor history for a printer."""
-    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
-
-    result = await db.execute(
-        select(func.count(PrinterSensorHistory.id)).where(
-            and_(
-                PrinterSensorHistory.printer_id == printer_id,
-                PrinterSensorHistory.recorded_at < cutoff,
-            )
-        )
-    )
-    count = result.scalar()
-
-    await db.execute(
-        PrinterSensorHistory.__table__.delete().where(
-            and_(
-                PrinterSensorHistory.printer_id == printer_id,
-                PrinterSensorHistory.recorded_at < cutoff,
-            )
-        )
-    )
-    await db.commit()
-
-    return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}

+ 241 - 281
backend/app/api/routes/printers.py

@@ -53,6 +53,8 @@ from backend.app.schemas.printer import (
 )
 from backend.app.services import camera_light as camera_light_service, drying_preflight, kprofile_drift
 from backend.app.services.bambu_ftp import (
+    DownloadDeadlineExceeded,
+    FileNotOnPrinterError,
     cache_3mf_download,
     delete_file_async,
     download_file_bytes_async,
@@ -62,12 +64,17 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     list_files_result_async,
 )
+from backend.app.services.hms_actions import HMSAction
 from backend.app.services.print_storage import ftp_probe_paths, print_file_reachable_over_ftp
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
+from backend.app.services.printer_file_cache import printer_file_cache
 from backend.app.services.printer_manager import (
+    ams_units_to_dicts,
     display_temperatures,
     drying_screen_only,
     get_derived_status_name,
+    nozzle_wiring,
+    print_ams_mapping,
     printer_manager,
     resolve_expected_tray,
     resolve_plate_id,
@@ -75,10 +82,11 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
-    uniform_tray_filament_hint,
+    vt_trays_to_dicts,
 )
 from backend.app.services.printer_media import (
     MAX_PRINTER_ZIP_PREPARE_SECONDS,
+    PrinterFileDownloadError,
     PrinterFilesZipInsufficientSpaceError,
     PrinterFilesZipTooLargeError,
     build_printer_file,
@@ -91,8 +99,8 @@ from backend.app.services.printer_media import (
     start_printer_files_job,
 )
 from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id
-from backend.app.services.slot_nozzle import resolve_slot_nozzle
-from backend.app.utils.ams_humidity import ams_humidity_percent
+from backend.app.services.slot_nozzle import reports_two_nozzles, resolve_slot_nozzle
+from backend.app.utils.filament_finish import different_finish, finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_IDS_REPLACED_ON_REUSE,
@@ -206,6 +214,11 @@ async def create_printer(
     db.add(printer)
     await db.commit()
     await db.refresh(printer)
+    # A new printer has no plate references: any under its id are a deleted
+    # printer's (SQLite reuses ids; deletes before this left them behind).
+    from backend.app.services.plate_detection import remove_printer_references
+
+    remove_printer_references(printer.id)
 
     # A group given this location reaches the new printer straight away (#1727)
     if await location_grantees(db, [printer.location]):
@@ -230,9 +243,10 @@ async def list_usb_cameras(
     Returns:
         List of dicts with {device: str, name: str, capabilities: list, formats?: list}
     """
-    from backend.app.services.external_camera import list_usb_cameras
+    from backend.app.services import external_camera
 
-    cameras = list_usb_cameras()
+    # v4l2-ctl runs twice per device, with a 5 s timeout each.
+    cameras = await asyncio.to_thread(external_camera.list_usb_cameras)
     return {"cameras": cameras}
 
 
@@ -277,6 +291,9 @@ async def get_available_filaments(
 
         # Get ams_extruder_map for dual-nozzle printers
         ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
+        # Same signal the scheduler matches with (#2771): without an AMS the map
+        # is empty on a dual-nozzle printer too.
+        is_dual_nozzle = reports_two_nozzles(status)
 
         # AMS trays
         for ams_unit in status.raw_data.get("ams", []):
@@ -324,7 +341,7 @@ async def get_available_filaments(
             tray_info_idx = vt.get("tray_info_idx", "")
             tray_sub_brands = vt.get("tray_sub_brands", "") or ""
             vt_id = int(vt.get("id", 254))
-            extruder_id = (255 - vt_id) if ams_extruder_map else None
+            extruder_id = (255 - vt_id) if is_dual_nozzle else None
 
             key = (vt_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
             if key not in seen:
@@ -428,6 +445,8 @@ async def update_printer(
             update_data["plate_detection_roi_w"] = None
             update_data["plate_detection_roi_h"] = None
 
+    ip_changed = "ip_address" in update_data and update_data["ip_address"] != printer.ip_address
+
     for field, value in update_data.items():
         setattr(printer, field, value)
 
@@ -437,6 +456,16 @@ async def update_printer(
     if access_moved:
         await ws_manager.refresh_printer_scopes()
 
+    # Proxy virtual printers forward to the IP they were started with; the
+    # sync restarts the ones aimed at this printer on the new one.
+    if ip_changed:
+        from backend.app.services.virtual_printer import virtual_printer_manager
+
+        try:
+            await virtual_printer_manager.sync_from_db()
+        except Exception as e:
+            logger.error("Failed to sync virtual printers after printer IP change: %s", e)
+
     # Reconnect if connection settings changed
     if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
         printer_manager.disconnect_printer(printer_id)
@@ -473,8 +502,51 @@ async def delete_printer(
     if not printer:
         raise HTTPException(404, "Printer not found")
 
+    if delete_archives:
+        # Deleting an archive removes its queue items; one that is printing
+        # (a reprint on another printer, say) must not go from under the
+        # dispatcher -- the same 409 as DELETE /archives/{id} (#1734).
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printing = await db.scalar(
+            select(func.count())
+            .select_from(PrintQueueItem)
+            .where(
+                PrintQueueItem.status == "printing",
+                PrintQueueItem.archive_id.in_(select(PrintArchive.id).where(PrintArchive.printer_id == printer_id)),
+            )
+        )
+        if printing:
+            raise HTTPException(
+                409,
+                f"Cannot delete this printer's archives — {printing} queue item(s) using them are "
+                f"currently printing. Stop the print first, or keep the archives.",
+            )
+
+    # What was limited to this printer goes with it, rather than reaching every
+    # printer (a provider's NULL printer means all) or, on SQLite, whichever
+    # printer is given this id next (an API key's list keeps the id). Providers
+    # first: retiring a Bambuddy Notify one commits.
+    from backend.app.api.routes.notifications import after_providers_deleted, delete_provider
+    from backend.app.core.printer_scope import drop_printers_from_api_keys, drop_printers_from_obico
+    from backend.app.models.notification import NotificationProvider
+
+    notify = False
+    scoped = await db.execute(select(NotificationProvider).where(NotificationProvider.printer_id == printer_id))
+    for provider in scoped.scalars().all():
+        notify = await delete_provider(db, provider) or notify
+
     printer_manager.disconnect_printer(printer_id)
 
+    # Locked from the archive sweep to the commit (PostgreSQL; SQLite's single
+    # writer serialises this anyway). A print start adopting or writing an
+    # archive for this printer meanwhile then waits and is refused by the
+    # printer's foreign key, instead of passing it and being deleted by the
+    # ORM delete's cascade below: also an archive the user chose to keep, or
+    # another printer's (measured). Not earlier: retiring a Notify provider
+    # above commits, which ends a lock; nothing commits from here on.
+    await db.execute(select(Printer.id).where(Printer.id == printer_id).with_for_update())
+
     if delete_archives:
         # Delete all archives for this printer
         await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
@@ -506,8 +578,48 @@ async def delete_printer(
         )
         await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))
 
+    await drop_printers_from_api_keys(db, [printer_id])
+    await drop_printers_from_obico(db, [printer_id])
+    # A virtual printer aimed at it goes too: without a target a queue-mode one
+    # takes jobs for any printer of its model (deleted below, with its plugs).
+    from backend.app.api.routes.virtual_printers import drop_virtual_printer, remove_upload_dir
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    aimed = (
+        (await db.execute(select(VirtualPrinter.id).where(VirtualPrinter.target_printer_id == printer_id)))
+        .scalars()
+        .all()
+    )
+    # Its smart plugs too: without a printer a plug is everyone's, and users and
+    # keys limited to other printers could switch what it still powers.
+    from backend.app.api.routes.smart_plugs import delete_plug
+    from backend.app.models.smart_plug import SmartPlug
+
+    for plug in (await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))).scalars().all():
+        await delete_plug(db, plug)
+    upload_dirs = []
+    for vp_id in aimed:
+        await virtual_printer_manager.remove_instance(vp_id)
+        upload_dirs.append(await drop_virtual_printer(db, vp_id))
+
     await db.delete(printer)
     await db.commit()
+    await after_providers_deleted(notify=notify)
+    for upload_dir in upload_dirs:
+        remove_upload_dir(upload_dir)
+    if aimed:
+        try:
+            await virtual_printer_manager.sync_from_db()
+        except Exception as e:
+            logger.error("Failed to sync virtual printers after printer delete: %s", e)
+    # Open connections worked out their printers at connect.
+    await ws_manager.refresh_printer_scopes()
+    # What is kept for its id in memory and on disk: SQLite gives the id to the
+    # next printer created.
+    from backend.app.main import forget_printer
+
+    await forget_printer(printer_id)
 
     return {"status": "deleted", "archives_deleted": delete_archives}
 
@@ -568,133 +680,13 @@ async def get_printer_status(
 
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         ams_exists = True
-        for ams_data in raw_data["ams"]:
-            # Skip if ams_data is not a dict (defensive check)
-            if not isinstance(ams_data, dict):
-                continue
-            trays = []
-            for tray_data in ams_data.get("tray", []):
-                # Filter out empty/invalid tag values
-                tag_uid = tray_data.get("tag_uid", "")
-                if tag_uid in ("", "0000000000000000"):
-                    tag_uid = None
-                tray_uuid = tray_data.get("tray_uuid", "")
-                if tray_uuid in ("", "00000000000000000000000000000000"):
-                    tray_uuid = None
-
-                # Get K value: first try tray's k field, then lookup from K-profiles
-                k_value = tray_data.get("k")
-                cali_idx = tray_data.get("cali_idx")
-                if k_value is None:
-                    k_value = _kprofile_k(cali_idx, int(ams_data.get("id", 0)), int(tray_data.get("id", 0)))
-
-                trays.append(
-                    AMSTray(
-                        id=tray_data.get("id", 0),
-                        tray_color=tray_data.get("tray_color"),
-                        tray_type=tray_data.get("tray_type"),
-                        tray_sub_brands=tray_data.get("tray_sub_brands"),
-                        tray_id_name=tray_data.get("tray_id_name"),
-                        tray_info_idx=tray_data.get("tray_info_idx"),
-                        remain=tray_data.get("remain", 0),
-                        k=k_value,
-                        cali_idx=cali_idx,
-                        tag_uid=tag_uid,
-                        tray_uuid=tray_uuid,
-                        nozzle_temp_min=tray_data.get("nozzle_temp_min"),
-                        nozzle_temp_max=tray_data.get("nozzle_temp_max"),
-                        drying_temp=tray_data.get("drying_temp"),
-                        drying_time=tray_data.get("drying_time"),
-                        state=tray_data.get("state"),
-                        exists=tray_data.get("exists"),
-                    )
-                )
-            # Percentage only. The 1-5 ``humidity`` index is never substituted
-            # for one -- it is inverted, so it would read as the opposite of
-            # what it means (#3140). See utils/ams_humidity.
-            humidity_pct = ams_humidity_percent(ams_data)
-            humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
-            # AMS-HT has 1 tray, regular AMS has 4 trays
-            is_ams_ht = len(trays) == 1
-
-            ams_id_int = int(ams_data.get("id", 0))
-            target = drying_targets.get(ams_id_int) or {}
-            dry_target_temp: int | None = None
-            dry_filament: str | None = None
-            target_temp_val = target.get("temp")
-            target_fil_val = target.get("filament") or ""
-            if target_temp_val is not None:
-                try:
-                    dry_target_temp = int(target_temp_val)
-                except (TypeError, ValueError):
-                    dry_target_temp = None
-            if target_fil_val:
-                dry_filament = str(target_fil_val)
-            # Fallback: name the filament from the loaded trays when there is no
-            # cached target (drying started in a previous backend session, or
-            # the cache wasn't seeded), and only when they agree. The
-            # temperature has no fallback — see uniform_tray_filament_hint.
-            if not dry_filament:
-                dry_filament = uniform_tray_filament_hint([tray.tray_type or "" for tray in trays])
-
-            ams_units.append(
-                AMSUnit(
-                    id=ams_id_int,
-                    humidity=humidity_value,
-                    temp=ams_data.get("temp"),
-                    is_ams_ht=is_ams_ht,
-                    tray=trays,
-                    # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
-                    serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
-                    # Firmware version: populated by _handle_version_info from info.module ams/* entries
-                    sw_ver=str(ams_data.get("sw_ver") or ""),
-                    # Drying: dry_time > 0 means drying is active (minutes remaining)
-                    dry_time=int(ams_data.get("dry_time") or 0),
-                    dry_countdown_stalled=bool(ams_data.get("dry_countdown_stalled") or False),
-                    dry_target_temp=dry_target_temp,
-                    dry_filament=dry_filament,
-                    module_type=str(ams_data.get("module_type") or ""),
-                )
-            )
+        # Same serialiser as the WebSocket push, so the card cannot lose a
+        # field between page load and the first push.
+        ams_units = [AMSUnit(**unit) for unit in ams_units_to_dicts(raw_data["ams"], _kprofile_k, drying_targets)]
 
     # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
     if "vt_tray" in raw_data:
-        for vt_data in raw_data["vt_tray"]:
-            # Filter out empty/invalid tag values for vt_tray
-            vt_tag_uid = vt_data.get("tag_uid", "")
-            if vt_tag_uid in ("", "0000000000000000"):
-                vt_tag_uid = None
-            vt_tray_uuid = vt_data.get("tray_uuid", "")
-            if vt_tray_uuid in ("", "00000000000000000000000000000000"):
-                vt_tray_uuid = None
-
-            # Get K value: first try tray's k field, then lookup from K-profiles
-            vt_k_value = vt_data.get("k")
-            vt_cali_idx = vt_data.get("cali_idx")
-            if vt_k_value is None:
-                # External holder: id 254 is Ext-L, 255 is Ext-R. slot_extruder
-                # takes the 0/1 tray index, so normalise before asking.
-                vt_id = int(vt_data.get("id", 254))
-                vt_k_value = _kprofile_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id)
-
-            tray_id = int(vt_data.get("id", 254))
-            vt_tray.append(
-                AMSTray(
-                    id=tray_id,
-                    tray_color=vt_data.get("tray_color"),
-                    tray_type=vt_data.get("tray_type"),
-                    tray_sub_brands=vt_data.get("tray_sub_brands"),
-                    tray_id_name=vt_data.get("tray_id_name"),
-                    tray_info_idx=vt_data.get("tray_info_idx"),
-                    remain=vt_data.get("remain", 0),
-                    k=vt_k_value,
-                    cali_idx=vt_cali_idx,
-                    tag_uid=vt_tag_uid,
-                    tray_uuid=vt_tray_uuid,
-                    nozzle_temp_min=vt_data.get("nozzle_temp_min"),
-                    nozzle_temp_max=vt_data.get("nozzle_temp_max"),
-                )
-            )
+        vt_tray = [AMSTray(**vt) for vt in vt_trays_to_dicts(raw_data["vt_tray"], _kprofile_k)]
 
     # Convert nozzle info to response format
     nozzles = [
@@ -741,8 +733,8 @@ async def get_printer_status(
         filament_tangle_detect=state.print_options.filament_tangle_detect,
     )
 
-    # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
-    ams_mapping = raw_data.get("ams_mapping", [])
+    # The running print's AMS mapping, for the slot pills (#1762).
+    ams_mapping = print_ams_mapping(state)
     # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
     # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
     ams_extruder_map = state.ams_extruder_map or {}
@@ -843,6 +835,8 @@ async def get_printer_status(
                 state.tray_tar,
                 [(u.id, u.is_ams_ht) for u in ams_units],
                 raw_data.get("mapping"),
+                active_extruder=state.active_extruder,
+                ams_extruder_map=nozzle_wiring(state),
             )
             if state.state == "PAUSE"
             else None
@@ -852,6 +846,8 @@ async def get_printer_status(
                 state.tray_pre,
                 [(u.id, u.is_ams_ht) for u in ams_units],
                 raw_data.get("mapping"),
+                active_extruder=state.active_extruder,
+                ams_extruder_map=nozzle_wiring(state),
             )
             if state.state == "PAUSE"
             else None
@@ -985,7 +981,9 @@ async def get_current_print_user(
         raise HTTPException(404, "Printer not found")
 
     user_info = printer_manager.get_current_print_user(printer_id)
-    return user_info or {}
+    if not user_info:
+        return {}
+    return {"user_id": user_info["user_id"], "username": user_info["username"]}
 
 
 @router.post("/{printer_id}/refresh-status")
@@ -1039,22 +1037,6 @@ async def disconnect_printer(
     return {"connected": False}
 
 
-@router.post("/test")
-async def test_printer_connection(
-    ip_address: str,
-    serial_number: str,
-    access_code: str,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
-):
-    """Test connection to a printer without saving."""
-    result = await printer_manager.test_connection(
-        ip_address=ip_address,
-        serial_number=serial_number,
-        access_code=access_code,
-    )
-    return result
-
-
 @router.post("/diagnostic", response_model=PrinterDiagnosticResult)
 async def diagnose_connection(
     req: DiagnosticRequest,
@@ -1076,7 +1058,6 @@ async def diagnose_connection(
 async def diagnose_printer(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
 ):
     """Run connection diagnostics for an existing saved printer.
 
@@ -1088,8 +1069,9 @@ async def diagnose_printer(
     """
     from backend.app.services.printer_diagnostic import PUBLISH_WAIT_DEFAULT
 
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
+    # A short session, released before the probes and the wait (as RC-C9).
+    async with database.async_session() as db:
+        printer = (await db.execute(select(Printer).where(Printer.id == printer_id))).scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
     return await run_connection_diagnostic(
@@ -1661,7 +1643,10 @@ async def list_printer_files(
     return {
         "path": path,
         "files": files,
-        "warnings": [] if listing.available else ["printer_unavailable"],
+        # Slow is not gone: a large folder on a slow card can outlast the wait (TO-C13).
+        "warnings": (
+            [] if listing.available else ["listing_timed_out"] if listing.timed_out else ["printer_unavailable"]
+        ),
     }
 
 
@@ -1686,10 +1671,12 @@ async def download_printer_file(
         raise HTTPException(413, str(exc)) from exc
     except PrinterFilesZipInsufficientSpaceError as exc:
         raise HTTPException(507, str(exc)) from exc
+    except PrinterFileDownloadError as exc:
+        raise HTTPException(502, f"Could not download {path} from the printer") from exc
     except FileNotFoundError:
         raise HTTPException(404, f"File not found: {path}")
     except TimeoutError as exc:
-        raise HTTPException(504, "Printer download exceeded the 30-minute limit") from exc
+        raise HTTPException(504, str(exc) or "Printer download exceeded the 30-minute limit") from exc
 
     # Determine content type based on extension
     filename = path.split("/")[-1]
@@ -1717,6 +1704,39 @@ async def download_printer_file(
     )
 
 
+async def _download_printer_file_bytes(printer, path: str) -> bytes:
+    """Download a printer file for a preview: 404 missing, 502 failed, 504 too slow.
+
+    A failed download used to be reported as 404 "File not found" for a file the
+    browser had just listed. One FTP session, no listing first, and shared: the
+    plate picker asks once per plate, all at once, and each request downloaded
+    the whole 3MF again (see services/printer_file_cache).
+    """
+
+    async def fetch() -> bytes:
+        try:
+            data = await download_file_bytes_async(
+                printer.ip_address,
+                printer.access_code,
+                path,
+                printer_model=printer.model,
+                report_missing=True,
+                # The size the printer reports buys a slow link its time, up to
+                # the file browser's budget: print start's 300 s ceiling would
+                # add none.
+                max_total_timeout=MAX_PRINTER_ZIP_PREPARE_SECONDS,
+            )
+        except FileNotOnPrinterError as exc:
+            raise HTTPException(404, f"File not found: {path}") from exc
+        except DownloadDeadlineExceeded as exc:
+            raise HTTPException(504, f"Downloading {path} from the printer took longer than its size allows") from exc
+        if data is None:
+            raise HTTPException(502, f"Could not download {path} from the printer")
+        return data
+
+    return await printer_file_cache.get(printer.id, path, fetch)
+
+
 @router.get("/{printer_id}/files/gcode")
 async def get_printer_file_gcode(
     printer_id: int,
@@ -1728,9 +1748,7 @@ async def get_printer_file_gcode(
 
     printer = await _load_printer_or_404(printer_id)
 
-    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
-    if data is None:
-        raise HTTPException(404, f"File not found: {path}")
+    data = await _download_printer_file_bytes(printer, path)
 
     filename = path.split("/")[-1]
     lower = filename.lower()
@@ -1775,9 +1793,7 @@ async def get_printer_file_plates(
             "is_multi_plate": False,
         }
 
-    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
-    if data is None:
-        raise HTTPException(404, f"File not found: {path}")
+    data = await _download_printer_file_bytes(printer, path)
 
     plates = []
 
@@ -1905,6 +1921,8 @@ async def get_printer_file_plates(
                                     "slot_id": int(filament_id),
                                     "type": filament_type,
                                     "color": filament_color,
+                                    # The preset the slicer assigned (GFA01, ...): names the product line
+                                    "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                 }
@@ -2002,9 +2020,7 @@ async def get_printer_file_plate_thumbnail(
 
     printer = await _load_printer_or_404(printer_id)
 
-    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
-    if data is None:
-        raise HTTPException(404, f"File not found: {path}")
+    data = await _download_printer_file_bytes(printer, path)
 
     try:
         with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
@@ -2163,6 +2179,9 @@ async def delete_printer_file(
     from backend.app.services.bambu_ftp import DeleteResult
 
     result = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
+    if result != DeleteResult.FAILED:
+        # Gone from the printer: a preview must not be served from the old copy.
+        printer_file_cache.forget(printer_id, path)
     if result == DeleteResult.NOT_FOUND:
         raise HTTPException(404, f"File not found on printer: {path}")
     if result == DeleteResult.FAILED:
@@ -2750,7 +2769,7 @@ async def get_slot_spool_defaults(
     from backend.app.services.inventory_mode import spoolman_owns_assignments
     from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
     from backend.app.services.slot_nozzle import resolve_slot_nozzle
-    from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
+    from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset_with_own
 
     state = printer_manager.get_status(printer_id)
     model = printer_manager.get_model(printer_id)
@@ -2781,13 +2800,11 @@ async def get_slot_spool_defaults(
             )
         ).scalar_one_or_none()
         if sm_assignment is not None:
-            slicer_filament, slicer_filament_name = await resolve_spoolman_preset(
+            slicer_filament, slicer_filament_name = await resolve_spoolman_preset_with_own(
                 db,
                 spoolman_spool_id=sm_assignment.spoolman_spool_id,
                 printer_model=model,
                 nozzle_diameter=slot_nozzle.diameter,
-                fallback_filament=None,
-                fallback_name=None,
             )
     else:
         assignment = (
@@ -2991,6 +3008,7 @@ async def configure_ams_slot(
         # No preset provided — try slot reuse or generic fallback
         current_tray_info_idx = ""
         current_tray_type = ""
+        current_tray_sub_brands = ""
         state = printer_manager.get_status(printer_id)
         if state and state.raw_data:
             from backend.app.api.routes.inventory import _find_tray_in_ams_data
@@ -3002,6 +3020,7 @@ async def configure_ams_slot(
                     if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                         current_tray_info_idx = vt.get("tray_info_idx", "")
                         current_tray_type = vt.get("tray_type", "")
+                        current_tray_sub_brands = vt.get("tray_sub_brands", "")
                         break
             else:
                 ams_data = state.raw_data.get("ams", {})
@@ -3016,6 +3035,7 @@ async def configure_ams_slot(
                 if cur_tray:
                     current_tray_info_idx = cur_tray.get("tray_info_idx", "")
                     current_tray_type = cur_tray.get("tray_type", "")
+                    current_tray_sub_brands = cur_tray.get("tray_sub_brands", "")
 
         if (
             # An Orca profile without an id must not inherit whatever specific
@@ -3026,6 +3046,8 @@ async def configure_ams_slot(
             and current_tray_info_idx not in GENERIC_IDS_REPLACED_ON_REUSE
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
+            # The same type in another finish is another preset.
+            and not different_finish(tray_sub_brands, current_tray_sub_brands)
         ):
             logger.info(
                 "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
@@ -3040,7 +3062,8 @@ async def configure_ams_slot(
             # to "PETG" would trade away for GFG99.
             material = requested_tray_type.upper().strip()
             generic = (
-                GENERIC_FILAMENT_IDS.get(material)
+                finish_generic_id(tray_type, f"{requested_tray_type} {tray_sub_brands}")
+                or GENERIC_FILAMENT_IDS.get(material)
                 or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
                 or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                 or ""
@@ -3139,6 +3162,7 @@ async def configure_ams_slot(
     # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
     # corrupting it — e.g., overwriting a High Flow extruder 1 profile with
     # hardcoded extruder_id=0 and nozzle_id=HS00.
+    k_value_unsent = False
     if k_value > 0 and cali_idx < 0:
         # Calculate global tray ID for extrusion_cali_set
         if ams_id <= 3:
@@ -3148,7 +3172,7 @@ async def configure_ams_slot(
         else:
             global_tray_id = tray_id
 
-        client.extrusion_cali_set(
+        k_value_unsent = not client.extrusion_cali_set(
             tray_id=global_tray_id,
             k_value=k_value,
             nozzle_diameter=nozzle_diameter,
@@ -3343,6 +3367,10 @@ async def configure_ams_slot(
         # "lookup_failed" or "no_permission". Cleared when a K-profile realigned
         # the slot to its own specific filament after all.
         "orca_fallback_reason": "" if realigned_to_kprofile else orca_fallback_reason,
+        # The filament setting went out but a manual K value did not (FW-C5).
+        # Only the send is known: the printer's answer to it is not trusted --
+        # an X1C answers "fail" to a K value it applied.
+        "k_value_unsent": k_value_unsent,
     }
 
 
@@ -3362,6 +3390,9 @@ async def reset_ams_slot(
     client = printer_manager.get_client(printer_id)
     if not client:
         raise HTTPException(status_code=400, detail="Printer not connected")
+    # A running print may be feeding from this tray.
+    if client.state and client.state.state in printer_manager.ACTIVE_PRINT_STATES:
+        raise HTTPException(status_code=409, detail="The printer is printing; reset the slot once it is idle")
 
     # Reset the slot
     success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
@@ -3503,54 +3534,6 @@ async def delete_ams_label(
     return {"success": True}
 
 
-@router.post("/{printer_id}/debug/simulate-print-complete")
-async def debug_simulate_print_complete(
-    printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-):
-    """DEBUG: Simulate print completion to test freeze behavior.
-
-    This triggers the same code path as a real print completion,
-    without needing to wait for an actual print to finish.
-    """
-    from backend.app.main import _active_prints, on_print_complete
-    from backend.app.models.archive import PrintArchive
-
-    # Get the most recent archive for this printer
-    result = await db.execute(
-        select(PrintArchive)
-        .where(PrintArchive.printer_id == printer_id)
-        .order_by(PrintArchive.created_at.desc())
-        .limit(1)
-    )
-    archive = result.scalar_one_or_none()
-
-    if not archive:
-        raise HTTPException(status_code=404, detail="No archives found for this printer")
-
-    # Register this archive as "active" so on_print_complete can find it
-    filename = archive.file_path.split("/")[-1] if archive.file_path else "test.3mf"
-    subtask_name = archive.print_name or "Test Print"
-    _active_prints[(printer_id, filename)] = archive.id
-    _active_prints[(printer_id, subtask_name)] = archive.id
-
-    # Simulate print completion data
-    data = {
-        "status": "completed",
-        "filename": filename,
-        "subtask_name": subtask_name,
-        "timelapse_was_active": False,
-    }
-
-    logger.info("Simulating print complete for printer %s, archive %s", printer_id, archive.id)
-
-    # Call the actual on_print_complete handler
-    await on_print_complete(printer_id, data)
-
-    return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"}
-
-
 # =============================================================================
 # Print Control Endpoints
 # =============================================================================
@@ -3572,20 +3555,12 @@ async def stop_print(
     if not client:
         raise HTTPException(400, "Printer not connected")
 
-    success = client.stop_print()
-    if not success:
-        raise HTTPException(500, "Failed to stop print")
+    # Sends the stop and marks the printer as stopped by the user, so the
+    # resulting "failed"/"aborted" status ends the print as cancelled.
+    from backend.app.services.print_control import stop_print_by_user
 
-    # Mark this printer as user-stopped so on_print_complete reclassifies
-    # the resulting "failed"/"aborted" MQTT status as "cancelled" — otherwise
-    # the HMS heuristic in _dispatch_archive_update mislabels user-cancels
-    # (e.g. the H2D's cancel-sequence module-0x0C HMS) as "Layer shift".
-    try:
-        from backend.app.main import mark_printer_stopped_by_user
-
-        mark_printer_stopped_by_user(printer_id)
-    except Exception as _mark_err:
-        logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err)
+    if not stop_print_by_user(printer_id):
+        raise HTTPException(500, "Failed to stop print")
 
     return {"success": True, "message": "Print stop command sent"}
 
@@ -3694,7 +3669,7 @@ async def resume_print(
 @router.post("/{printer_id}/print-speed")
 async def set_print_speed(
     printer_id: int,
-    mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
+    mode: int = Query(..., ge=1, le=4, description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
@@ -4172,7 +4147,6 @@ async def get_printable_objects(
     printer_id: int,
     reload: bool = False,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get the list of printable objects for the current print.
 
@@ -4182,8 +4156,10 @@ async def get_printable_objects(
     Args:
         reload: If True, reload objects from the archive file (useful after restart)
     """
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
+    # Short sessions, released before the FTP download below (RC-C9): it can
+    # take a while from a printer that is mid-print.
+    async with database.async_session() as db:
+        printer = (await db.execute(select(Printer).where(Printer.id == printer_id))).scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
 
@@ -4206,16 +4182,17 @@ async def get_printable_objects(
 
         subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip()
         if subtask_id not in ("", "0"):
-            archive = await db.scalar(
-                select(PrintArchive)
-                .where(
-                    PrintArchive.printer_id == printer_id,
-                    PrintArchive.status == "printing",
-                    PrintArchive.subtask_id == subtask_id,
+            async with database.async_session() as db:
+                archive = await db.scalar(
+                    select(PrintArchive)
+                    .where(
+                        PrintArchive.printer_id == printer_id,
+                        PrintArchive.status == "printing",
+                        PrintArchive.subtask_id == subtask_id,
+                    )
+                    .order_by(PrintArchive.created_at.desc())
+                    .limit(1)
                 )
-                .order_by(PrintArchive.created_at.desc())
-                .limit(1)
-            )
             if archive is not None:
                 objects, bbox_all = extract_printable_objects_from_archive(
                     settings.base_dir / archive.file_path,
@@ -4629,19 +4606,26 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
 
 
 # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
-# a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
-_LOAD_TRAY_ID_ERROR = "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
+# a2l-am-unit-16. An AMS-HT holds one tray, numbered by its unit id 128-135, as
+# the printer's tray_now reports it. All are valid alongside the regular 0-15.
+_LOAD_TRAY_ID_ERROR = (
+    "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 128..135 (AMS-HT), 254 (external / Ext-L), or 255 (Ext-R)"
+)
 
 
 def _is_valid_load_tray_id(tray_id: int) -> bool:
     """Whether ``tray_id`` names a slot the load/unload commands can address."""
-    return tray_id in range(16) or tray_id in range(24, 28) or tray_id in (254, 255)
+    return tray_id in range(16) or tray_id in range(24, 28) or tray_id in range(128, 136) or tray_id in (254, 255)
 
 
 @router.post("/{printer_id}/ams/load")
 async def ams_load(
     printer_id: int,
-    tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"),
+    tray_id: int = Query(
+        ...,
+        description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 128-135 for an AMS-HT (its unit id), "
+        "254 for external spool",
+    ),
     extruder_id: int | None = Query(
         None,
         ge=0,
@@ -4661,6 +4645,7 @@ async def ams_load(
 
     Tray ID encoding (matches Bambu firmware convention):
     - 0..15: AMS slot, computed as ams_id * 4 + slot_id
+    - 128..135: AMS-HT, the unit id (one tray per unit)
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
     """
@@ -4684,6 +4669,8 @@ async def ams_load(
         target = "external spool"
     elif tray_id == 255:
         target = "Ext-R"
+    elif tray_id >= 128:
+        target = f"AMS HT-{chr(ord('A') + tray_id - 128)}"
     else:
         target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
     return {"success": True, "message": f"Loading filament from {target}"}
@@ -4730,40 +4717,6 @@ async def ams_unload(
     return {"success": True, "message": "Unloading filament"}
 
 
-@router.get("/{printer_id}/runtime-debug")
-async def get_runtime_debug(
-    printer_id: int,
-    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
-):
-    """Debug endpoint: Get runtime tracking status for a printer."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
-
-    state = printer_manager.get_status(printer_id)
-
-    return {
-        "printer_name": printer.name,
-        "runtime_seconds": printer.runtime_seconds,
-        "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,
-        "print_hours_offset": printer.print_hours_offset,
-        "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)
-        + (printer.print_hours_offset or 0),
-        "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,
-        "mqtt_state": {
-            "connected": state.connected if state else False,
-            "state": state.state if state else None,
-            "progress": state.progress if state else None,
-            "gcode_file": state.gcode_file if state else None,
-        }
-        if state
-        else None,
-        "is_active": printer.is_active,
-    }
-
-
 @router.post("/{printer_id}/hms/execute-action")
 async def execute_hms_action(
     printer_id: int,
@@ -4801,6 +4754,13 @@ async def execute_hms_action(
     if not success:
         raise HTTPException(400, "Failed to execute HMS action")
 
+    if body.action == HMSAction.STOP_PRINTING:
+        # A user stop like the Stop button: mark it before the wait below, so a
+        # completion arriving meanwhile ends the print as cancelled, not failed.
+        from backend.app.services.print_control import mark_stopped_by_user
+
+        mark_stopped_by_user(printer_id)
+
     # Give the printer time to push a state update. The dispatch helper already
     # publishes a pushall after every command, so a fresh status should arrive
     # within ~1s; the default 2.5s covers slower firmware variants without

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 300 - 386
backend/app/api/routes/projects.py


+ 265 - 195
backend/app/api/routes/settings.py

@@ -1,4 +1,5 @@
 import io
+import json
 import logging
 import os
 import zipfile
@@ -12,19 +13,24 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     ScopedCaller,
     caller_is_api_key,
+    is_auth_enabled,
     require_auth_if_enabled,
     require_energy_cost_update,
 )
 from backend.app.core.config import APP_VERSION, settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
-from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+from backend.app.schemas.settings import ADMIN_ONLY_SETTING_KEYS, AppSettings, AppSettingsUpdate
 from backend.app.services import camera_light
+from backend.app.services.secret_fields import SETTINGS_SECRETS, SecretConfigError, mask_fields, merge_partial
 
 logger = logging.getLogger(__name__)
 
@@ -32,14 +38,9 @@ router = APIRouter(prefix="/settings", tags=["settings"])
 
 DEFAULT_SETTINGS = AppSettings()
 
-# Sensitive credential fields blanked for API-key callers
-_SENSITIVE_FIELDS_FOR_API_KEY = (
-    "mqtt_password",
-    "ha_token",
-    "prometheus_token",
-    "virtual_printer_access_code",
-    "ldap_bind_password",
-)
+# Sensitive credential fields blanked for API-key callers. Everyone else sees
+# them as the placeholder (write-only secrets, see services/secret_fields).
+_SENSITIVE_FIELDS_FOR_API_KEY = tuple(SETTINGS_SECRETS)
 
 
 async def get_setting(db: AsyncSession, key: str) -> str | None:
@@ -189,7 +190,6 @@ _BOOL_SETTING_KEYS = frozenset(
         "include_beta_updates",
         "announcements_enabled",
         "announcements_all_users",
-        "virtual_printer_enabled",
         "ftp_retry_enabled",
         "mqtt_enabled",
         "mqtt_use_tls",
@@ -265,8 +265,12 @@ _INT_SETTING_KEYS = frozenset(
 )
 
 
-async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -> AppSettings:
-    """Build the full settings response, scrubbing secrets for API-key callers."""
+async def _build_settings_response(db: AsyncSession, is_api_key: bool = False, mask: bool = True) -> AppSettings:
+    """Build the full settings response.
+
+    Secrets are blank for API-key callers and the placeholder for everyone else;
+    ``mask=False`` is for server-side use only (comparing with an update).
+    """
     settings_dict = DEFAULT_SETTINGS.model_dump()
 
     result = await db.execute(select(Settings))
@@ -307,13 +311,17 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
     ha_settings = await get_homeassistant_settings(db)
     settings_dict.update(ha_settings)
 
-    # ldap_bind_password is never returned to any caller
-    settings_dict["ldap_bind_password"] = ""
+    # ldap_bind_password is never returned to any caller (mask=False is
+    # server-side only)
+    if mask or is_api_key:
+        settings_dict["ldap_bind_password"] = ""
 
     if is_api_key:
         for field in _SENSITIVE_FIELDS_FOR_API_KEY:
             if field in settings_dict:
                 settings_dict[field] = ""
+    elif mask:
+        settings_dict = mask_fields(settings_dict, SETTINGS_SECRETS)
 
     return AppSettings(**settings_dict)
 
@@ -329,6 +337,20 @@ async def get_settings(
     return await _build_settings_response(db, is_api_key=_is_api_key)
 
 
+def _as_stored_setting(value: object) -> str:
+    """The text the settings writer below stores for ``value``."""
+    if isinstance(value, bool):
+        return "true" if value else "false"
+    if value is None:
+        return "None"
+    return str(value)
+
+
+def _same_setting_value(new: object, old: object) -> bool:
+    """Whether saving ``new`` stores exactly what ``old`` is stored as."""
+    return _as_stored_setting(new) == _as_stored_setting(old)
+
+
 @router.put("/", response_model=AppSettings)
 async def update_settings(
     settings_update: AppSettingsUpdate,
@@ -338,6 +360,19 @@ async def update_settings(
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
 
+    # Write-only secrets: the placeholder keeps the stored value, and a kept
+    # secret does not follow a changed destination.
+    stored = (await _build_settings_response(db, mask=False)).model_dump()
+    secrets = dict(SETTINGS_SECRETS)
+    if stored.get("ha_token_from_env"):
+        # The deployment's own token, not a stored secret to enter again: the
+        # URL may change (the placeholder sent back still writes nothing).
+        secrets["ha_token"] = ()
+    try:
+        update_data = merge_partial(stored, update_data, secrets)
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
+
     # An explicit null for a boolean or numeric setting has no meaning -- these
     # are not clearable -- and would be stored as the literal "None".
     null_keys = sorted(
@@ -348,6 +383,37 @@ async def update_settings(
     if null_keys:
         raise HTTPException(status_code=422, detail=f"These settings cannot be null: {', '.join(null_keys)}")
 
+    # Settings that decide who can sign in are admin-only, like the user routes.
+    # Only a real change counts: the settings page sends every field on each
+    # save, so an unchanged value from a non-admin must pass.
+    if current_user is not None and not current_user.is_admin:
+        changed = sorted(
+            key
+            for key in update_data
+            if key in ADMIN_ONLY_SETTING_KEYS and not _same_setting_value(update_data[key], stored.get(key))
+        )
+        if changed:
+            raise HTTPException(
+                status_code=403,
+                detail=f"Only an administrator can change these settings: {', '.join(changed)}",
+            )
+
+    # Like an API key's or a provider's printers: a page still open from before
+    # a printer delete would save the deleted id, which SQLite gives to the next
+    # printer created (the validator has already checked it is a list of ints).
+    # Only ids not stored yet are checked: the page sends the whole list, and an
+    # id already there must not block every save.
+    if update_data.get("obico_enabled_printers"):
+        from backend.app.core.printer_scope import ensure_printers_exist
+
+        try:
+            stored = json.loads(await get_setting(db, "obico_enabled_printers") or "[]")
+        except ValueError:
+            stored = []
+        stored = set(stored) if isinstance(stored, list) else set()
+        added = [i for i in json.loads(update_data["obico_enabled_printers"]) if i not in stored]
+        await ensure_printers_exist(db, added)
+
     # Safety refusals on disabling local login (#1589). Two failure modes
     # would otherwise lock everyone out of the install:
     #   1. No enabled OIDC provider exists — nobody could authenticate.
@@ -397,13 +463,7 @@ async def update_settings(
 
     for key, value in update_data.items():
         # Convert value to string for storage
-        if isinstance(value, bool):
-            str_value = "true" if value else "false"
-        elif value is None:
-            str_value = "None"
-        else:
-            str_value = str(value)
-        await set_setting(db, key, str_value)
+        await set_setting(db, key, _as_stored_setting(value))
 
     await db.commit()
     # Expire all objects to ensure fresh reads after commit
@@ -441,7 +501,7 @@ async def update_settings(
         except Exception:
             pass  # Don't fail the settings update if MQTT reconfiguration fails
 
-    # Return updated settings (never scrub secrets on PUT — caller has SETTINGS_UPDATE permission)
+    # Return updated settings, secrets as the placeholder
     return await _build_settings_response(db, is_api_key=False)
 
 
@@ -493,6 +553,7 @@ async def update_electricity_price(
 @router.post("/reset", response_model=AppSettings)
 async def reset_settings(
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Reset all settings to defaults."""
@@ -596,6 +657,46 @@ _UI_FLAG_FIELDS: tuple[str, ...] = (
     # update check ran even where it had been switched off.
     "currency",
     "check_updates",
+    # Read by the print dialog, queue, file manager, inventory and SpoolBuddy
+    # pages, which users without SETTINGS_READ open too: the 403 dropped them
+    # all to their defaults (with billing on, the print dialog hid the cost
+    # centre picker and the print was then refused for lacking one).
+    "queue_shortest_first",
+    "use_slicer_api",
+    "open_in_slicer",
+    "preferred_slicer",
+    "disable_filament_warnings",
+    "prefer_lowest_filament",
+    "per_printer_mapping_expanded",
+    "default_filament_cost",
+    "default_bed_levelling",
+    "default_flow_cali",
+    "default_vibration_cali",
+    "default_layer_inspect",
+    "default_timelapse",
+    "default_nozzle_offset_cali",
+    "default_confirm_outcome",
+    "stagger_group_size",
+    "stagger_interval_minutes",
+    "library_disk_warning_gb",
+    "low_stock_threshold",
+    "location_sensor_poll_interval",
+    "location_sensor_alert_defaults",
+    "pipeline_max_copies",
+    "language",
+    "dark_style",
+    "dark_background",
+    "dark_accent",
+    "light_style",
+    "light_background",
+    "light_accent",
+    # Which inventory a new spool goes to (unknown-tag prompt), and the
+    # forecast's default lead time.
+    "spoolman_enabled",
+    "forecast_global_lead_time_days",
+    # Preselected in the print dialog; withheld below from a caller who may
+    # not use that printer.
+    "default_printer_id",
 )
 
 
@@ -603,6 +704,7 @@ _UI_FLAG_FIELDS: tuple[str, ...] = (
 async def get_ui_flags(
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_auth_if_enabled),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Install configuration the app shell needs, for any signed-in user.
 
@@ -619,7 +721,12 @@ async def get_ui_flags(
     """
     full = await _build_settings_response(db, is_api_key=False)
     dumped = full.model_dump()
-    return {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
+    flags = {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
+    if flags.get("default_printer_id") is not None and not printer_scope.allows(flags["default_printer_id"]):
+        flags["default_printer_id"] = None
+    # Whether G-code snippets are set up, not the snippets themselves.
+    flags["has_gcode_snippets"] = bool(dumped.get("gcode_snippets"))
+    return flags
 
 
 @router.get("/check-ffmpeg")
@@ -923,6 +1030,7 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
 @router.get("/backup")
 async def create_backup(
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
     """Create a complete backup (database + all files) as a ZIP download."""
@@ -1057,7 +1165,21 @@ def _read_backup_manifest(temp_path: Path) -> dict:
     return data if isinstance(data, dict) else {}
 
 
-async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
+def _restored_value_fixes(table_name: str) -> dict[str, tuple[tuple[str, ...], str]]:
+    """Columns of ``table_name`` whose backed-up value must be one of a set, with its fallback.
+
+    Older versions stored any project priority or status; one longer than the
+    column fails the whole import on PostgreSQL. The fallbacks are the ones
+    project import and the start-up repair use.
+    """
+    if table_name != "projects":
+        return {}
+    from backend.app.models.project import PROJECT_PRIORITIES, PROJECT_STATUSES
+
+    return {"priority": (PROJECT_PRIORITIES, "normal"), "status": (PROJECT_STATUSES, "active")}
+
+
+async def _import_sqlite_to_postgres(sqlite_path: Path):
     """Import data from a SQLite database file into the current PostgreSQL database.
 
     Used for cross-database restore (SQLite backup → PostgreSQL).
@@ -1067,7 +1189,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
 
     from sqlalchemy import text
 
-    from backend.app.core.database import Base, _create_engine
+    from backend.app.core.database import AUTOINCREMENT_TABLES, Base, _create_engine, highest_referenced_id
 
     # Before anything is dropped. The route checks this too, earlier and with
     # the backup's version in the message; this call is what makes the guarantee
@@ -1088,6 +1210,11 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
             "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'"
         )
         src_tables = {row["name"] for row in cursor.fetchall()}
+        # Where SQLite's own counters stand: ids freed there are not given out
+        # again on PostgreSQL either (a backup from before they were kept has none).
+        src_counters: dict[str, int] = {}
+        if src.execute("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'").fetchone():
+            src_counters = {row["name"]: row["seq"] for row in src.execute("SELECT name, seq FROM sqlite_sequence")}
 
         # Get Postgres tables from our ORM models
         metadata = Base.metadata
@@ -1205,7 +1332,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
 
                 # Identify columns that need type conversion (SQLite stores booleans
                 # as int and datetimes as str — asyncpg requires native Python types)
-                from datetime import datetime as dt
+                from datetime import datetime as dt, timezone
 
                 bool_columns = set()
                 datetime_columns = set()
@@ -1237,7 +1364,10 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                                     if sd is not None:
                                         not_null_defaults[col.name] = sd
 
-                now = dt.now()
+                # Naive UTC, as every stored timestamp is: the host's local
+                # time put backfilled rows hours off (DB-C4).
+                now = dt.now(timezone.utc).replace(tzinfo=None)
+                value_fixes = _restored_value_fixes(table_name)
 
                 def _convert_row(
                     row,
@@ -1247,6 +1377,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                     nn_defaults=not_null_defaults,
                     _now=now,
                     inject=injected,
+                    fixes=value_fixes,
                 ):
                     result = dict(inject)
                     for c in cols:
@@ -1262,19 +1393,30 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                                 except ValueError:
                                     pass
                         result[c] = val
+                    for c, (allowed, fallback) in fixes.items():
+                        if c in result and result[c] not in allowed:
+                            result[c] = fallback
                     return result
 
                 batch = [_convert_row(row) for row in rows]
                 await conn.execute(insert_sql, batch)
                 logger.info("Imported %d rows into %s", len(batch), table_name)
 
-            # Reset sequences to max(id) + 1 for each table with an id column
+            # Reset sequences to max(id) + 1 for each table with an id column;
+            # for AUTOINCREMENT_TABLES past SQLite's counter and every id still
+            # referred to, so a deleted row's id is not given to a new one.
             for table_name in sorted_tables:
                 try:
                     async with conn.begin_nested():
                         result = await conn.execute(text(f"SELECT MAX(id) FROM {table_name}"))  # noqa: S608  # nosec B608
                         max_id = result.scalar()
-                        if max_id is not None:
+                        if table_name in AUTOINCREMENT_TABLES:
+                            max_id = max(
+                                max_id or 0,
+                                src_counters.get(table_name) or 0,
+                                await highest_referenced_id(conn, table_name),
+                            )
+                        if max_id:
                             seq_name = f"{table_name}_id_seq"
                             await conn.execute(text(f"SELECT setval('{seq_name}', {max_id})"))  # noqa: S608
                 except Exception:
@@ -1327,6 +1469,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
 async def restore_backup(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
     """Restore from a complete backup ZIP.
@@ -1407,11 +1550,16 @@ async def restore_backup(
         try:
             import asyncio
 
-            # 3. Stop virtual printer if running (releases file locks)
+            # 3. Stop the virtual printers if running (releases file locks).
+            # stop_all, not the legacy configure(): that ignores its arguments
+            # and re-syncs from the database not yet replaced, so their FTP,
+            # MQTT and SSDP services ran on through the restore (vp-2). Like
+            # the services below, they come back with the restart a
+            # successful restore asks for.
             try:
                 if virtual_printer_manager.is_enabled:
-                    logger.info("Stopping virtual printer for restore...")
-                    await virtual_printer_manager.configure(enabled=False)
+                    logger.info("Stopping virtual printers for restore...")
+                    await virtual_printer_manager.stop_all()
                     await asyncio.sleep(1)
             except Exception as e:
                 logger.warning("Failed to stop virtual printer: %s", e)
@@ -1534,7 +1682,7 @@ async def restore_backup(
             else:
                 # Import SQLite backup into PostgreSQL
                 logger.info("Importing SQLite backup into PostgreSQL...")
-                await _import_sqlite_to_postgres(backup_db, app_settings.database_url)
+                await _import_sqlite_to_postgres(backup_db)
 
             # 6. Replace data directories
             # For Docker compatibility: clear contents then copy (don't delete mount points)
@@ -1594,6 +1742,35 @@ async def restore_backup(
             await reinitialize_database()
             await init_db()
 
+            # 9. Sign everyone out. The restored users and auth tokens come from
+            # another point in time, possibly written by older code, so no
+            # session issued before the restore should stay valid.
+            from backend.app.core import database as _db_mod
+
+            async with _db_mod.engine.begin() as conn:
+                await _db_mod.invalidate_all_sessions(conn)
+            # Open dashboards of signed-in users too: they are never signed in
+            # again. API-key and anonymous ones are not closed outright (nothing
+            # ended theirs), but the restored data decides what they may still see: the anonymous ones
+            # go if the backup turns authentication on, and the refresh closes
+            # those of a key the backup does not have (or has under another key).
+            from backend.app.core.auth import invalidate_auth_enabled_cache
+            from backend.app.core.websocket import ws_manager
+
+            # Read from the restored data: this request has just cached the
+            # setting it had before.
+            invalidate_auth_enabled_cache()
+            # Cloud presets were cached per user of the replaced user table.
+            from backend.app.api.routes.cloud import forget_all_cloud_presets
+
+            forget_all_cloud_presets()
+            async with _db_mod.async_session() as auth_db:
+                auth_on = await is_auth_enabled(auth_db)
+            if auth_on:
+                await ws_manager.close_signed_in()
+                await ws_manager.close_anonymous()
+            await ws_manager.refresh_printer_scopes()
+
             logger.info("Restore complete - restart required")
             message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."
             if skipped_dirs:
@@ -1644,40 +1821,33 @@ async def get_virtual_printer_models(
     }
 
 
+def _legacy_vp_status(status: dict, printer_scope: PrinterScope) -> dict:
+    """The first virtual printer's status, without what names its target for a limited caller."""
+    if printer_scope.is_unrestricted:
+        return status
+    return {key: value for key, value in status.items() if key not in ("name", "serial", "target_printer_ip", "proxy")}
+
+
 @router.get("/virtual-printer")
 async def get_virtual_printer_settings(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Get virtual printer settings and status."""
-    from backend.app.services.virtual_printer import (
-        DEFAULT_VIRTUAL_PRINTER_MODEL,
-        virtual_printer_manager,
-    )
+    """Get the settings this route still sets, and the virtual printers' status.
+
+    Every other virtual printer setting lives on its own row
+    (``GET /virtual-printers``).
+    """
+    from backend.app.services.virtual_printer import virtual_printer_manager
 
-    enabled = await get_setting(db, "virtual_printer_enabled")
-    access_code = await get_setting(db, "virtual_printer_access_code")
-    mode = await get_setting(db, "virtual_printer_mode")
-    model = await get_setting(db, "virtual_printer_model")
-    target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
-    remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
     tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
     archive_name_source = await get_setting(db, "virtual_printer_archive_name_source")
 
-    from backend.app.models.virtual_printer import VP_MODE_ARCHIVE, normalize_vp_mode
-
     return {
-        "enabled": enabled == "true" if enabled else False,
-        "access_code_set": bool(access_code),
-        # Normalize on read so older settings rows (with `immediate` /
-        # `print_queue`) come out as `archive` / `queue` for the frontend.
-        "mode": normalize_vp_mode(mode) or VP_MODE_ARCHIVE,
-        "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
-        "target_printer_id": int(target_printer_id) if target_printer_id else None,
-        "remote_interface_ip": remote_interface_ip or "",
         "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
         "archive_name_source": archive_name_source if archive_name_source in ("metadata", "filename") else "metadata",
-        "status": virtual_printer_manager.get_status(),
+        "status": _legacy_vp_status(virtual_printer_manager.get_status(), printer_scope),
     }
 
 
@@ -1693,174 +1863,74 @@ async def update_virtual_printer_settings(
     archive_name_source: str = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Update virtual printer settings and restart services if needed.
+    """Legacy single-printer settings: the archive name source and the first
+    virtual printer's Tailscale switch.
 
-    For proxy mode with SSDP proxy (dual-homed setup):
-    - remote_interface_ip: IP of interface on slicer's network (LAN B)
-    - Local interface is auto-detected based on target printer IP
+    Every other virtual printer setting lives on its own row, set with
+    ``PUT /virtual-printers/{id}``; the rest of this route's parameters are
+    refused (vp-3).
     """
     from sqlalchemy import select
 
-    from backend.app.models.printer import Printer
-    from backend.app.services.virtual_printer import (
-        DEFAULT_VIRTUAL_PRINTER_MODEL,
-        VIRTUAL_PRINTER_MODELS,
-        virtual_printer_manager,
-    )
+    from backend.app.models.virtual_printer import VirtualPrinter as FirstVP
 
-    # Get current values
-    current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
-    current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
-    # Default to `archive` (the canonical name) but tolerate legacy `immediate`
-    # in the stored value — normalized later before validation.
-    current_mode = await get_setting(db, "virtual_printer_mode") or "archive"
-    current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
-    current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
-    current_ts_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
-    # Default True (opt-in) when the setting has never been saved — matches the model default.
-    current_ts_disabled = current_ts_disabled_raw == "true" if current_ts_disabled_raw else True
-
-    # Apply updates
-    new_enabled = enabled if enabled is not None else current_enabled
-    new_access_code = access_code if access_code is not None else current_access_code
-    new_mode = mode if mode is not None else current_mode
-    new_model = model if model is not None else current_model
-    new_target_id = target_printer_id if target_printer_id is not None else current_target_id
-    new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
-    new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
-
-    # Validate mode. Canonical wire values are `archive` / `review` / `queue`
-    # / `proxy`; legacy `immediate` and `print_queue` are accepted as aliases
-    # and translated before storage so support bundles stop showing the old
-    # confusing pair (#1429 mode-label discrepancy).
-    from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
-
-    canonical_mode = normalize_vp_mode(new_mode)
-    if canonical_mode not in VP_MODE_VALUES:
+    # One bridging to a printer the caller can't use is not theirs to change,
+    # and they can't aim it at such a printer.
+    # The first virtual printer is the one these settings reach.
+    first_vp = (await db.execute(select(FirstVP).order_by(FirstVP.position, FirstVP.id).limit(1))).scalar_one_or_none()
+    if not printer_scope.allows(current_target_id) or (
+        first_vp is not None and not printer_scope.allows(first_vp.target_printer_id)
+    ):
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if target_printer_id is not None and not printer_scope.allows(target_printer_id):
+        return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
+    # Each virtual printer keeps these itself now; this route wrote them to
+    # settings keys nothing reads and answered 200 as if applied (vp-3). The
+    # stored legacy values are not checked either: an old install's stale
+    # mode or deleted target printer refused the settings this still serves.
+    unread = [
+        name
+        for name, value in (
+            ("enabled", enabled),
+            ("access_code", access_code),
+            ("mode", mode),
+            ("model", model),
+            ("target_printer_id", target_printer_id),
+            ("remote_interface_ip", remote_interface_ip),
+        )
+        if value is not None
+    ]
+    if unread:
         return JSONResponse(
             status_code=400,
             content={
-                "detail": f"Mode must be one of: {', '.join(VP_MODE_VALUES)}",
+                "detail": f"{', '.join(unread)} cannot be set here any more: set them for each virtual printer "
+                "with PUT /api/v1/virtual-printers/{id}"
             },
         )
-    new_mode = canonical_mode
 
-    # Validate archive_name_source
     if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
         return JSONResponse(
             status_code=400,
             content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
         )
 
-    # Validate model
-    if model is not None and model not in VIRTUAL_PRINTER_MODELS:
-        return JSONResponse(
-            status_code=400,
-            content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
-        )
-
-    # Mode-specific validation and printer lookup
-    target_printer_ip = ""
-    target_printer_serial = ""
-    if new_mode == "proxy":
-        # Proxy mode requires target printer when enabling
-        if new_enabled and not new_target_id:
-            # If just switching to proxy mode (not explicitly enabling), auto-disable
-            if enabled is None:
-                new_enabled = False
-            else:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": "Target printer is required for proxy mode"},
-                )
-
-        # Look up printer IP and serial if we have a target
-        if new_target_id:
-            result = await db.execute(select(Printer).where(Printer.id == new_target_id))
-            printer = result.scalar_one_or_none()
-            if not printer:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": f"Printer with ID {new_target_id} not found"},
-                )
-            target_printer_ip = printer.ip_address
-            target_printer_serial = printer.serial_number
-        # Access code not required for proxy mode
-    else:
-        # Non-proxy modes require access code when enabling
-        if new_enabled and not new_access_code:
-            # If just switching modes (not explicitly enabling), auto-disable
-            if enabled is None:
-                new_enabled = False
-            else:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": "Access code is required when enabling virtual printer"},
-                )
-
-        # Validate access code length (Bambu Studio requires exactly 8 characters)
-        if access_code is not None and access_code and len(access_code) != 8:
-            return JSONResponse(
-                status_code=400,
-                content={"detail": "Access code must be exactly 8 characters"},
-            )
-
-    # Save settings
-    await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
-    if access_code is not None:
-        await set_setting(db, "virtual_printer_access_code", access_code)
-    await set_setting(db, "virtual_printer_mode", new_mode)
-    if model is not None:
-        await set_setting(db, "virtual_printer_model", model)
-    if target_printer_id is not None:
-        await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
-    if remote_interface_ip is not None:
-        await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
     if tailscale_disabled is not None:
         await set_setting(db, "virtual_printer_tailscale_disabled", "true" if tailscale_disabled else "false")
+        # The first virtual printer's card shows its Tailscale address by it.
+        if first_vp is not None:
+            first_vp.tailscale_disabled = tailscale_disabled
     if archive_name_source is not None:
         await set_setting(db, "virtual_printer_archive_name_source", archive_name_source)
 
-    # Propagate tailscale_disabled to the first VirtualPrinter row so sync_from_db() picks it up
-    if tailscale_disabled is not None:
-        from backend.app.models.virtual_printer import VirtualPrinter as VPModel
-
-        vp_result = await db.execute(select(VPModel).order_by(VPModel.position).limit(1))
-        first_vp = vp_result.scalar_one_or_none()
-        if first_vp is not None:
-            first_vp.tailscale_disabled = new_ts_disabled
-
     await db.commit()
     db.expire_all()
 
-    # Reconfigure virtual printer
-    try:
-        await virtual_printer_manager.configure(
-            enabled=new_enabled,
-            access_code=new_access_code,
-            mode=new_mode,
-            model=new_model,
-            target_printer_ip=target_printer_ip,
-            target_printer_serial=target_printer_serial,
-            remote_interface_ip=new_remote_iface,
-        )
-    except ValueError as e:
-        logger.warning("Virtual printer configuration validation error: %s", e)
-        return JSONResponse(
-            status_code=400,
-            content={"detail": "Invalid virtual printer configuration. Check the provided values."},
-        )
-    except Exception as e:
-        logger.error("Failed to configure virtual printer: %s", e, exc_info=True)
-        return JSONResponse(
-            status_code=500,
-            content={"detail": "Failed to configure virtual printer. Check server logs for details."},
-        )
-
-    return await get_virtual_printer_settings(db)
+    return await get_virtual_printer_settings(db, printer_scope=printer_scope)
 
 
 # =============================================================================

+ 6 - 6
backend/app/api/routes/slice_jobs.py

@@ -7,7 +7,7 @@ status flips to `completed` or `failed`.
 
 from fastapi import APIRouter, Depends, HTTPException
 
-from backend.app.core.auth import require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.services.slice_dispatch import slice_dispatch
@@ -22,7 +22,7 @@ async def get_slice_job(
     # the resulting library_file_id / archive_id. Gate on the library read
     # permission family (own/all) and then scope per-row: a READ_OWN caller may
     # only poll jobs they started (SliceJob.owner_id).
-    auth: tuple[User | None, bool] = Depends(
+    auth: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -33,10 +33,10 @@ async def get_slice_job(
     job = slice_dispatch.get(job_id)
     if job is None:
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
-    # Per-row scoping. Jobs started by API-key / auth-disabled callers have
-    # owner_id=None and are visible only to READ_ALL pollers (fail-closed,
-    # mirrors the library ownerless-row rule). 404 not 403 to avoid job-id
-    # enumeration.
+    # Per-row scoping. Jobs recorded without an owner (auth off, a key without
+    # an owner, an archive slice through a key) are visible only to READ_ALL
+    # pollers (fail-closed, mirrors the library ownerless-row rule). 404 not
+    # 403 to avoid job-id enumeration.
     if not can_read_all and (user is None or job.owner_id != user.id):
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
     body: dict = {

+ 2 - 0
backend/app/api/routes/slicer_pipelines.py

@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ensure_printers_exist
 from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.user import User
 from backend.app.schemas.slicer import PresetRef
@@ -165,6 +166,7 @@ async def update_pipeline(
         if data.target_printer_id == 0:
             row.target_printer_id = None
         else:
+            await ensure_printers_exist(db, [data.target_printer_id])
             row.target_printer_id = data.target_printer_id
     # PR C — class targeting + fanout strategy. Empty string from the frontend
     # also clears the class (radio toggled away).

+ 8 - 3
backend/app/api/routes/slicer_presets.py

@@ -27,6 +27,7 @@ from backend.app.api.routes.orca_cloud import (
     _load_credentials as _load_orca_credentials,
 )
 from backend.app.core.auth import (
+    ApiKeyActor,
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     is_auth_enabled,
@@ -788,8 +789,8 @@ async def list_loaded_spools(
                 _loaded_spool_tray(t, ams_id, int(t.get("id", 0)), saved.get((printer.id, ams_id, int(t.get("id", 0)))))
                 for t in raw_trays
             ]
-            # Same rule as the printer status: an AMS-HT reports a single tray.
-            units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=len(trays) == 1, trays=trays))
+            # Same rule as the printer status: an AMS-HT is a unit id 128-135.
+            units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=128 <= ams_id <= 135, trays=trays))
 
         external: list[LoadedSpoolTray] = []
         holders = [vt for vt in raw_data.get("vt_tray") or [] if isinstance(vt, dict)]
@@ -818,7 +819,7 @@ async def list_loaded_spools(
 async def get_preview_slice_progress(
     request_id: str,
     db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -848,6 +849,10 @@ async def get_preview_slice_progress(
     try:
         async with httpx.AsyncClient(timeout=5.0) as client:
             response = await client.get(url)
+    except httpx.TimeoutException:
+        # Busy, not gone: a heavy slice can hold the progress answer past 5 s
+        # (TO-C14). Still 503, so the poller keeps trying.
+        raise HTTPException(status_code=503, detail="Slicer sidecar did not answer in time") from None
     except httpx.RequestError:
         # Sidecar unreachable: surface as 503 instead of 500 so the
         # frontend's poller can keep trying without flagging a hard error.

+ 193 - 168
backend/app/api/routes/smart_plugs.py

@@ -1,10 +1,10 @@
 """API routes for smart plug management."""
 
 import logging
-from datetime import timedelta
 
 from fastapi import APIRouter, Body, Depends, HTTPException
-from pydantic import BaseModel
+from fastapi.exceptions import RequestValidationError
+from pydantic import BaseModel, ValidationError
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -13,6 +13,7 @@ from backend.app.core.auth import (
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
+    probe_permissions_if_auth_enabled,
 )
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -28,10 +29,12 @@ from backend.app.schemas.smart_plug import (
     HATestConnectionResponse,
     RESTTestConnectionRequest,
     RESTTestConnectionResponse,
+    SmartPlugBase,
     SmartPlugControl,
     SmartPlugCreate,
     SmartPlugEnergy,
     SmartPlugResponse,
+    SmartPlugResponseWithSecret,
     SmartPlugStatus,
     SmartPlugTestConnection,
     SmartPlugUpdate,
@@ -40,33 +43,72 @@ from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
-from backend.app.services.notification_service import notification_service
 from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
+from backend.app.services.secret_fields import (
+    REST_PLUG_URLS,
+    SECRET_PLACEHOLDER,
+    SMART_PLUG_SECRETS,
+    SecretConfigError,
+    merge_partial,
+    reject_placeholder,
+    same_value,
+)
+from backend.app.services.smart_plug_manager import publish_plug_state, run_linked_scripts, smart_plug_manager
 from backend.app.services.tasmota import tasmota_service
-from backend.app.utils.local_time import to_naive_utc, utcnow_naive
+from backend.app.utils.local_time import utcnow_naive
 
 logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 
+# Whether the caller may edit plugs, and so gets the REST URLs and bodies.
+CallerEditsPlugs = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE))
+
+
+def _serialize_plug(plug: SmartPlug, *, can_edit: bool) -> SmartPlugResponse:
+    """The response shape that matches the caller's authority.
+
+    Routes returning it declare no response_model: validating the reader shape
+    again would run the REST URL checks on the fields it leaves out.
+    """
+    if can_edit:
+        return SmartPlugResponseWithSecret.model_validate(plug)
+    return SmartPlugResponse.model_validate(plug)
 
-@router.get("/", response_model=list[SmartPlugResponse])
+
+@router.get("/", response_model=None)
 async def list_smart_plugs(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
 ):
     """List all smart plugs, minus those powering printers the caller can't see (#1727)."""
     query = select(SmartPlug).order_by(SmartPlug.name)
     if (clause := printer_scope.where(SmartPlug.printer_id)) is not None:
         query = query.where(clause)
     result = await db.execute(query)
-    return list(result.scalars().all())
+    return [_serialize_plug(plug, can_edit=can_edit) for plug in result.scalars().all()]
 
 
-@router.post("/", response_model=SmartPlugResponse)
+async def _printer_has_other_tasmota_plug(
+    db: AsyncSession, printer_id: int, exclude_plug_id: int | None = None
+) -> bool:
+    """Whether the printer already has a Tasmota plug (other than this one).
+
+    One per printer: a Tasmota plug is the printer's power device, while HA
+    entities may be several (different automations). Tolerates printers that
+    already hold more than one from before every path checked this.
+    """
+    query = select(SmartPlug.id).where(SmartPlug.printer_id == printer_id, SmartPlug.plug_type == "tasmota")
+    if exclude_plug_id is not None:
+        query = query.where(SmartPlug.id != exclude_plug_id)
+    return (await db.execute(query.limit(1))).first() is not None
+
+
+@router.post("/", response_model=SmartPlugResponseWithSecret)
 async def create_smart_plug(
     data: SmartPlugCreate,
     db: AsyncSession = Depends(get_db),
@@ -74,8 +116,12 @@ async def create_smart_plug(
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Create a new smart plug."""
-    # Validate printer_id if provided
-    if data.printer_id:
+    try:
+        reject_placeholder({field: getattr(data, field) for field in SMART_PLUG_SECRETS})
+    except SecretConfigError as exc:
+        raise HTTPException(400, str(exc)) from None
+    # Validate printer_id if provided (0 included: it names no printer either)
+    if data.printer_id is not None:
         printer_scope.ensure(data.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
@@ -84,15 +130,8 @@ async def create_smart_plug(
         # Check if printer already has a plug assigned
         # Tasmota plugs: only one per printer (physical power device)
         # HA entities: allow multiple per printer (for different automations)
-        if data.plug_type == "tasmota":
-            result = await db.execute(
-                select(SmartPlug).where(
-                    SmartPlug.printer_id == data.printer_id,
-                    SmartPlug.plug_type == "tasmota",
-                )
-            )
-            if result.scalar_one_or_none():
-                raise HTTPException(400, "This printer already has a Tasmota plug assigned")
+        if data.plug_type == "tasmota" and await _printer_has_other_tasmota_plug(db, data.printer_id):
+            raise HTTPException(400, "This printer already has a Tasmota plug assigned")
 
     # For MQTT plugs, ensure MQTT broker is configured and service is connected
     if data.plug_type == "mqtt":
@@ -149,6 +188,20 @@ async def create_smart_plug(
     return plug
 
 
+async def _plug_for_secret_test(
+    db: AsyncSession, plug_id: int | None, can_update: bool, printer_scope: PrinterScope
+) -> SmartPlug:
+    """The saved plug whose stored secret a connection test may use."""
+    if plug_id is None:
+        raise HTTPException(400, "Enter the secret; nothing is saved for a new plug")
+    if not can_update:
+        raise HTTPException(403, "Testing a saved plug's secret requires smart_plugs:update")
+    plug = await db.get(SmartPlug, plug_id)
+    if plug is None or not printer_scope.allows(plug.printer_id):
+        raise HTTPException(404, "Smart plug not found")
+    return plug
+
+
 def _is_script_plug(plug: SmartPlug) -> bool:
     """Whether the plug is a Home Assistant script rather than a switchable device."""
     return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
@@ -233,11 +286,12 @@ async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlu
     return list(result.scalars().all())
 
 
-@router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
+@router.get("/by-printer/{printer_id}", response_model=None)
 async def get_smart_plug_by_printer(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    can_edit: bool = CallerEditsPlugs,
 ):
     """Get the main smart plug assigned to a printer.
 
@@ -245,7 +299,8 @@ async def get_smart_plug_by_printer(
     script -- returns the one that best fits the card's power controls. See
     ``_main_plug_rank`` for the order and why.
     """
-    return _pick_main_plug(await _plugs_for_printer(db, printer_id))
+    plug = _pick_main_plug(await _plugs_for_printer(db, printer_id))
+    return _serialize_plug(plug, can_edit=can_edit) if plug else None
 
 
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
@@ -341,7 +396,7 @@ class DiscoveredTasmotaDevice(BaseModel):
 @router.post("/discover/scan", response_model=TasmotaScanStatus)
 async def start_tasmota_scan(
     request: TasmotaScanRequest | None = Body(default=None),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
 ):
     """Start an IP range scan for Tasmota devices.
 
@@ -382,7 +437,7 @@ async def get_tasmota_scan_status(
 
 @router.post("/discover/stop", response_model=TasmotaScanStatus)
 async def stop_tasmota_scan(
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
 ):
     """Stop the current Tasmota scan."""
     tasmota_scanner.stop()
@@ -417,20 +472,52 @@ async def get_discovered_tasmota_devices(
 @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
 async def test_ha_connection(
     request: HATestConnectionRequest,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update_settings: bool = Depends(probe_permissions_if_auth_enabled(Permission.SETTINGS_UPDATE)),
 ):
-    """Test connection to Home Assistant."""
-    result = await homeassistant_service.test_connection(request.url, request.token)
+    """Test connection to Home Assistant.
+
+    The placeholder token tests the stored token, against the stored URL only.
+    """
+    token = request.token
+    if token == SECRET_PLACEHOLDER:
+        if not can_update_settings:
+            raise HTTPException(403, "Testing the saved token requires settings:update")
+        from backend.app.api.routes.settings import get_homeassistant_settings
+
+        stored = await get_homeassistant_settings(db)
+        if not stored.get("ha_token"):
+            raise HTTPException(400, "Enter the token; none is saved")
+        if not same_value(request.url, stored.get("ha_url")):
+            raise HTTPException(400, "Enter the token again: the URL changed")
+        token = stored["ha_token"]
+    result = await homeassistant_service.test_connection(request.url, token)
     return HATestConnectionResponse(**result)
 
 
 @router.post("/rest/test-connection", response_model=RESTTestConnectionResponse)
 async def test_rest_connection(
     request: RESTTestConnectionRequest,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Test connection to a REST/HTTP endpoint."""
-    result = await rest_smart_plug_service.test_connection(request.url, request.method, request.headers)
+    """Test connection to a REST/HTTP endpoint.
+
+    With plug_id, the placeholder headers are the plug's stored headers, for
+    the plug's own URLs only.
+    """
+    headers = request.headers
+    if headers == SECRET_PLACEHOLDER:
+        plug = await _plug_for_secret_test(db, request.plug_id, can_update, printer_scope)
+        if not plug.rest_headers:
+            raise HTTPException(400, "Enter the headers; none are saved")
+        if not any(same_value(request.url, getattr(plug, field)) for field in REST_PLUG_URLS):
+            raise HTTPException(400, "Enter the headers again: the URL is not one of the plug's")
+        headers = plug.rest_headers
+    result = await rest_smart_plug_service.test_connection(request.url, request.method, headers)
     return RESTTestConnectionResponse(**result)
 
 
@@ -487,22 +574,23 @@ async def list_ha_sensor_entities(
     return [HASensorEntity(**s) for s in sensors]
 
 
-@router.get("/{plug_id}", response_model=SmartPlugResponse)
+@router.get("/{plug_id}", response_model=None)
 async def get_smart_plug(
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
 ):
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
-    return plug
+    return _serialize_plug(plug, can_edit=can_edit)
 
 
-@router.patch("/{plug_id}", response_model=SmartPlugResponse)
+@router.patch("/{plug_id}", response_model=SmartPlugResponseWithSecret)
 async def update_smart_plug(
     plug_id: int,
     data: SmartPlugUpdate,
@@ -518,8 +606,19 @@ async def update_smart_plug(
 
     update_data = data.model_dump(exclude_unset=True)
 
+    # Write-only secrets: the placeholder keeps the stored value, and a kept
+    # secret does not follow a changed address or plug type.
+    stored = {
+        field: getattr(plug, field)
+        for field in {*SMART_PLUG_SECRETS, *(f for fs in SMART_PLUG_SECRETS.values() for f in fs)}
+    }
+    try:
+        update_data = merge_partial(stored, update_data, SMART_PLUG_SECRETS)
+    except SecretConfigError as exc:
+        raise HTTPException(400, str(exc)) from None
+
     # Validate new printer_id if being changed
-    if "printer_id" in update_data and update_data["printer_id"]:
+    if "printer_id" in update_data and update_data["printer_id"] is not None:
         new_printer_id = update_data["printer_id"]
         printer_scope.ensure(new_printer_id)
 
@@ -528,20 +627,31 @@ async def update_smart_plug(
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-        # Check if that printer already has a different Tasmota plug assigned
-        # Tasmota plugs: only one per printer (physical power device)
-        # HA entities: allow multiple per printer (for different automations)
-        new_plug_type = update_data.get("plug_type", plug.plug_type)
-        if new_plug_type == "tasmota":
-            result = await db.execute(
-                select(SmartPlug).where(
-                    SmartPlug.printer_id == new_printer_id,
-                    SmartPlug.id != plug_id,
-                    SmartPlug.plug_type == "tasmota",
-                )
-            )
-            if result.scalar_one_or_none():
-                raise HTTPException(400, "This printer already has a Tasmota plug assigned")
+    # One Tasmota plug per printer (it is the printer's power device), checked
+    # when this edit makes the plug a Tasmota plug on a printer, by its type or
+    # its printer: a type change alone cannot add a second, and an edit that
+    # changes neither is not refused for plugs stored earlier.
+    merged_type = update_data.get("plug_type", plug.plug_type)
+    merged_printer_id = update_data.get("printer_id", plug.printer_id)
+    becomes_tasmota_here = merged_type != plug.plug_type or merged_printer_id != plug.printer_id
+    if merged_type == "tasmota" and merged_printer_id and becomes_tasmota_here:
+        if await _printer_has_other_tasmota_plug(db, merged_printer_id, exclude_plug_id=plug_id):
+            raise HTTPException(400, "This printer already has a Tasmota plug assigned")
+
+    # The stored plug with the change applied must pass what create checks:
+    # a plug that fails it can no longer be read back (list, detail and this
+    # response all fail). The error names the field to fix, sent or stored,
+    # and carries no values: the stored plug holds write-only credentials.
+    merged = {field: update_data.get(field, getattr(plug, field, None)) for field in SmartPlugBase.model_fields}
+    try:
+        SmartPlugBase.model_validate(merged)
+    except ValidationError as exc:
+        raise RequestValidationError(
+            [
+                {"type": error["type"], "loc": ("body", *error["loc"]), "msg": error["msg"]}
+                for error in exc.errors(include_url=False, include_input=False, include_context=False)
+            ]
+        ) from None
 
     # Track old MQTT settings for comparison
     old_plug_type = plug.plug_type
@@ -596,6 +706,14 @@ async def update_smart_plug(
     return plug
 
 
+async def delete_plug(db: AsyncSession, plug: SmartPlug) -> None:
+    """Delete a smart plug (the caller commits). An MQTT plug stops listening first."""
+    smart_plug_manager.forget_plug(plug.id)
+    if plug.plug_type == "mqtt":
+        mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+    await db.delete(plug)
+
+
 @router.delete("/{plug_id}")
 async def delete_smart_plug(
     plug_id: int,
@@ -610,13 +728,7 @@ async def delete_smart_plug(
         raise HTTPException(404, "Smart plug not found")
 
     plug_name = plug.name
-    plug_type = plug.plug_type
-
-    # Unsubscribe MQTT plug before deletion
-    if plug_type == "mqtt":
-        mqtt_relay.smart_plug_service.unsubscribe(plug_id)
-
-    await db.delete(plug)
+    await delete_plug(db, plug)
     await db.commit()
 
     logger.info("Deleted smart plug '%s'", plug_name)
@@ -695,64 +807,24 @@ async def control_smart_plug(
         plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
     )
     if is_main_plug and plug.printer_id and expected_state:
-        await trigger_associated_scripts(plug.printer_id, expected_state, db)
+        await run_linked_scripts(plug.printer_id, expected_state, db)
 
-    # MQTT relay - publish smart plug state change
-    if expected_state:
+    # MQTT relay - publish smart plug state change. A toggle doesn't say which
+    # way it went, so read the state it ended in.
+    published_state = expected_state
+    if published_state is None:
         try:
-            from backend.app.services.mqtt_relay import mqtt_relay
-
-            # Get printer name if linked
-            printer_name = None
-            if plug.printer_id:
-                result = await db.execute(select(Printer).where(Printer.id == plug.printer_id))
-                printer = result.scalar_one_or_none()
-                printer_name = printer.name if printer else None
-
-            await mqtt_relay.on_smart_plug_state(
-                plug_id=plug.id,
-                plug_name=plug.name,
-                state="on" if expected_state == "ON" else "off",
-                printer_id=plug.printer_id,
-                printer_name=printer_name,
-            )
+            published_state = (await service.get_status(plug)).get("state")
         except Exception:
-            pass  # Don't fail if MQTT fails
+            published_state = None
+    if published_state in ("ON", "OFF"):
+        await publish_plug_state(
+            plug.id, plug.name, plug.printer_id, on=published_state == "ON", ha_entity_id=plug.ha_entity_id
+        )
 
     return {"success": True, "action": control.action}
 
 
-async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
-    """Trigger scripts linked to a printer based on main plug state change.
-
-    When the main plug turns ON, triggers scripts with auto_on=True.
-    When the main plug turns OFF, triggers scripts with auto_off=True.
-    """
-    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plugs = result.scalars().all()
-
-    # Find scripts that should be triggered
-    for plug in plugs:
-        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
-        if not is_script:
-            continue
-
-        should_trigger = False
-        if plug_state == "ON" and plug.auto_on:
-            should_trigger = True
-            logger.info("Auto-triggering script '%s' on printer power-on", plug.name)
-        elif plug_state == "OFF" and plug.auto_off:
-            should_trigger = True
-            logger.info("Auto-triggering script '%s' on printer power-off", plug.name)
-
-        if should_trigger:
-            try:
-                service = await _get_service_for_plug(plug, db)
-                await service.turn_on(plug)  # Scripts are triggered by calling turn_on
-            except Exception as e:
-                logger.error("Failed to trigger script '%s': %s", plug.name, e)
-
-
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(
     plug_id: int,
@@ -784,9 +856,6 @@ async def get_plug_status(
                     power=data.power,
                     today=data.energy,
                 )
-                # Check power alerts
-                if data.power is not None:
-                    await check_power_alerts(plug, data.power, db)
 
             return SmartPlugStatus(
                 state=data.state,
@@ -824,9 +893,8 @@ async def get_plug_status(
             # knows its own daily figures, is left alone.
             energy = await fill_derived_energy(db, plug.id, energy)
             energy_data = SmartPlugEnergy(**energy)
-
-            # Check power alerts
-            await check_power_alerts(plug, energy.get("power"), db)
+            # Power alerts are checked in the background (services/power_alerts.py),
+            # not here: this is only read while someone has a printer page open.
 
     return SmartPlugStatus(
         state=status["state"],
@@ -836,74 +904,31 @@ async def get_plug_status(
     )
 
 
-async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
-    """Check if power crosses alert thresholds and send notifications."""
-    if not plug.power_alert_enabled or current_power is None:
-        return
-
-    # Cooldown: don't alert more than once per 5 minutes
-    cooldown_minutes = 5
-    if plug.power_alert_last_triggered:
-        # Naive UTC on both sides: the column is naive, so a row loaded fresh from
-        # the DB comes back without an offset and subtracting an aware now() would
-        # raise TypeError.
-        time_since_last = utcnow_naive() - to_naive_utc(plug.power_alert_last_triggered)
-        if time_since_last < timedelta(minutes=cooldown_minutes):
-            return
-
-    alert_triggered = False
-    alert_type = None
-    threshold = None
-
-    # Check high threshold
-    if plug.power_alert_high is not None and current_power > plug.power_alert_high:
-        alert_triggered = True
-        alert_type = "high"
-        threshold = plug.power_alert_high
-
-    # Check low threshold
-    if plug.power_alert_low is not None and current_power < plug.power_alert_low:
-        alert_triggered = True
-        alert_type = "low"
-        threshold = plug.power_alert_low
-
-    if alert_triggered:
-        plug.power_alert_last_triggered = utcnow_naive()
-        await db.commit()
-
-        # Send notification
-        title = f"Power Alert: {plug.name}"
-        if alert_type == "high":
-            message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
-        else:
-            message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
-
-        logger.info("Power alert triggered for %s: %s", plug.name, message)
-
-        # Use printer_error event type for power alerts (closest match)
-        await notification_service.send_notification(
-            event_type="printer_error",
-            title=title,
-            message=message,
-            printer_id=plug.printer_id,
-            printer_name=plug.name,
-            context={
-                "error_type": f"Power {alert_type.title()}",
-                "error_detail": message,
-            },
-        )
-
-
 @router.post("/test-connection")
 async def test_connection(
     data: SmartPlugTestConnection,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Test connection to a Tasmota device."""
+    """Test connection to a Tasmota device.
+
+    With plug_id, the placeholder password is the plug's stored password, for
+    the plug's own address only.
+    """
+    password = data.password
+    if password == SECRET_PLACEHOLDER:
+        plug = await _plug_for_secret_test(db, data.plug_id, can_update, printer_scope)
+        if not plug.password:
+            raise HTTPException(400, "Enter the password; none is saved")
+        if not same_value(data.ip_address, plug.ip_address):
+            raise HTTPException(400, "Enter the password again: the address changed")
+        password = plug.password
     result = await tasmota_service.test_connection(
         data.ip_address,
         data.username,
-        data.password,
+        password,
     )
 
     if not result["success"]:

+ 76 - 59
backend/app/api/routes/spoolbuddy.py

@@ -4,6 +4,7 @@ import asyncio
 import contextlib
 import json
 import logging
+import re
 import time
 from datetime import datetime, timedelta, timezone
 from urllib.parse import urlparse
@@ -37,7 +38,6 @@ from backend.app.schemas.spoolbuddy import (
     TagRemovedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
-    UpdateStatusRequest,
     WriteTagRequest,
     WriteTagResultRequest,
 )
@@ -70,6 +70,19 @@ async def _translate_spoolbuddy_errors():
         raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
 
 
+async def _spoolman_mode(db: AsyncSession) -> bool:
+    """Spoolman mode as every SpoolBuddy screen reads it: switched on, with a URL.
+
+    Distinct from having a usable client: a URL the SSRF guard refuses leaves the
+    mode on but no client, and the spools are still Spoolman's.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    enabled = await get_setting(db, "spoolman_enabled")
+    url = await get_setting(db, "spoolman_url")
+    return bool(enabled) and enabled.lower() == "true" and bool((url or "").strip())
+
+
 async def _get_spoolman_client_or_none(db: AsyncSession):
     """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
     from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
@@ -458,10 +471,19 @@ async def nfc_tag_scanned(
         )
         tray_uuid = None
 
-    # _get_spoolman_client_or_none returns a usable client when spoolman_enabled
-    # is true (and the URL passes the SSRF guard), None otherwise — so its
-    # return value doubles as the mode discriminator.
+    # A usable client when Spoolman is on and its URL passes the SSRF guard.
+    # With the mode on but no usable client the spools are still Spoolman's,
+    # and the built-in table must not answer.
     client = await _get_spoolman_client_or_none(db)
+    if client is None and await _spoolman_mode(db):
+        await ws_manager.broadcast(
+            {
+                "type": "spoolman_unavailable",
+                "device_id": req.device_id,
+                "context": "nfc_tag_scanned",
+            }
+        )
+        return {"status": "ok", "matched": False, "spool_id": None}
 
     if client is not None:
         # Spoolman mode — exclusive lookup, no local-DB fallback.
@@ -642,7 +664,12 @@ async def nfc_write_tag(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
-    """Queue an NFC tag write command for a SpoolBuddy device."""
+    """Queue an NFC tag write command for a SpoolBuddy device.
+
+    The spool comes from the inventory in use: Spoolman when it is enabled,
+    otherwise Bambuddy's own. Built-in spools are kept after switching to
+    Spoolman, so one with the same id must not stand in for the Spoolman spool.
+    """
     from backend.app.models.spool import Spool
     from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
 
@@ -652,22 +679,20 @@ async def nfc_write_tag(
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    # Try local DB first
-    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = result.scalar_one_or_none()
+    sm_client = await _get_spoolman_client_or_none(db)
+    if sm_client is None and await _spoolman_mode(db):
+        raise HTTPException(status_code=503, detail="Spoolman is unavailable")
 
     nfc_warnings: list[str] = []
-    if spool:
+    if sm_client is None:
+        spool = (await db.execute(select(Spool).where(Spool.id == req.spool_id))).scalar_one_or_none()
+        if spool is None:
+            raise HTTPException(status_code=404, detail="Spool not found")
         ndef_data = encode_opentag3d(spool)
         data_origin = "local"
     else:
-        # Local DB miss — fall back to Spoolman when enabled
         from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 
-        sm_client = await _get_spoolman_client_or_none(db)
-        if sm_client is None:
-            raise HTTPException(status_code=404, detail="Spool not found")
-
         async with _translate_spoolbuddy_errors():
             sm_spool = await sm_client.get_spool(req.spool_id)
 
@@ -683,19 +708,36 @@ async def nfc_write_tag(
                 detail="Spoolman spool has no material set — cannot encode NFC tag",
             )
 
+        # A Bambu spool created from the AMS is linked by its tray UUID in
+        # extra.tag; a sticker would replace it there, and the next AMS sync,
+        # no longer finding the spool, would create a duplicate (SM-C5). Not
+        # when Spoolman also keeps that UUID as a native tag (0.27+): the sync
+        # finds the spool by it first, and the sticker leaves it alone.
+        from backend.app.api.routes._spoolman_helpers import _extract_extra_str
+
+        extra_tag = _extract_extra_str(sm_spool.get("extra") or {}, "tag").strip().upper()
+        native_uids = {str(t.get("uid", "")).upper() for t in sm_spool.get("tags") or [] if isinstance(t, dict)}
+        if re.fullmatch(r"[0-9A-F]{32}", extra_tag) and extra_tag not in native_uids:
+            raise HTTPException(
+                status_code=409,
+                detail="This spool is linked to its Bambu RFID tag through the AMS; "
+                "a sticker would replace that link in Spoolman",
+            )
+
         ndef_data = encode_opentag3d_from_mapped(mapped)
         data_origin = "spoolman"
 
         # Warn when fields that drive NFC content are absent in Spoolman.
-        # color_name specifically must check the raw filament field, not the
-        # mapped value — _map_spoolman_spool falls back to the filament's
-        # subtype when color_name is unset (so LinkSpoolModal stops showing
-        # "Unknown color"), but the NFC tag should still warn when Spoolman
-        # has no genuine color_name on file. Without this, the fallback
-        # silently masks a real missing-data condition.
-        raw_filament: dict = sm_spool.get("filament") or {}
-        if not raw_filament.get("color_name"):
-            nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name")
+        # The colour name is read the way the tag gets it: from the spool's
+        # extra (where Bambuddy keeps it -- Spoolman's filament has no such
+        # field, so checking that warned on every spool, SM-C6), else the
+        # subtype stands in for it.
+        if mapped.get("color_name") is None:
+            nfc_warnings.append("color_name not set in Spoolman — tag encodes an empty color name")
+        elif mapped.get("color_name_is_synthesized"):
+            nfc_warnings.append(
+                f"color_name not set in Spoolman — tag encodes the subtype ({mapped['color_name']}) as its color name"
+            )
         if not mapped.get("nozzle_temp_min"):
             nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
         if not mapped.get("subtype"):
@@ -776,6 +818,7 @@ async def nfc_write_result(
                         "spool_id": req.spool_id,
                         "tag_uid": req.tag_uid,
                         "message": "Spoolman not configured",
+                        "reason": "spoolman_not_configured",
                     }
                 )
                 raise HTTPException(
@@ -895,6 +938,7 @@ async def nfc_write_result(
                         # Generic message — full exception (may contain internal URLs/hostnames)
                         # is logged server-side only to prevent information leakage via WebSocket.
                         "message": "Spoolman link failed",
+                        "reason": "spoolman_link_failed",
                     }
                 )
                 raise HTTPException(
@@ -919,6 +963,7 @@ async def nfc_write_result(
                         "device_id": req.device_id,
                         "spool_id": req.spool_id,
                         "message": "Spool not found",
+                        "reason": "spool_not_found",
                     }
                 )
                 return {"status": "ok", "linked": False, "message": "Spool not found"}
@@ -1015,6 +1060,8 @@ async def update_spool_weight(
     from backend.app.models.spool import Spool
 
     sm_client = await _get_spoolman_client_or_none(db)
+    if sm_client is None and await _spoolman_mode(db):
+        raise HTTPException(status_code=503, detail="Spoolman is unavailable")
 
     if sm_client is None:
         # Local mode — exclusive update, no Spoolman fallback.
@@ -1027,7 +1074,11 @@ async def update_spool_weight(
         spool.weight_used = max(0, spool.label_weight - net_filament)
         spool.last_scale_weight = req.weight_grams
         spool.last_weighed_at = datetime.now(timezone.utc)
+        # A measured figure, which the AMS remain% sync must not overwrite
+        # (as a hand-set one, see update_spool).
+        spool.weight_locked = True
         await db.commit()
+        await ws_manager.broadcast({"type": "inventory_changed"})
         logger.info(
             "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
             spool.id,
@@ -1056,6 +1107,7 @@ async def update_spool_weight(
 
     async with _translate_spoolbuddy_errors():
         await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
+    await ws_manager.broadcast({"type": "inventory_changed"})
 
     weight_used = max(0.0, label_weight - remaining_weight)
     logger.info(
@@ -1341,7 +1393,7 @@ async def queue_diagnostic(
     device_id: str,
     diagnostic: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Queue a hardware diagnostic to run on the SpoolBuddy device.
 
@@ -1532,41 +1584,6 @@ async def get_ssh_public_key(
         raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
 
 
-@router.post("/devices/{device_id}/update-status")
-async def report_update_status(
-    device_id: str,
-    req: UpdateStatusRequest,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-):
-    """Daemon reports update progress back to the backend."""
-    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
-    device = result.scalar_one_or_none()
-    if not device:
-        raise HTTPException(status_code=404, detail="Device not registered")
-
-    device.update_status = req.status
-    device.update_message = req.message
-    # Only "complete" clears pending_command here. "error" leaves it set so the user can retry
-    # via the UI. The SSH service's own _update_progress clears on both "complete" and "error"
-    # because it owns the full update lifecycle end-to-end.
-    if req.status == "complete":
-        device.pending_command = None
-    await db.commit()
-
-    logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message)
-    await ws_manager.broadcast(
-        {
-            "type": "spoolbuddy_update",
-            "device_id": device_id,
-            "update_status": req.status,
-            "update_message": req.message,
-        }
-    )
-
-    return {"status": "ok"}
-
-
 # --- Background watchdog ---
 
 

+ 115 - 70
backend/app/api/routes/spoolman.py

@@ -26,8 +26,11 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
+from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId
 from backend.app.services import slot_unlink_grace
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.ams_slot_presence import spool_present
+from backend.app.services.inventory_mode import spoolman_owns_assignments
+from backend.app.services.printer_manager import PrinterManager, printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_filament_preset import resolve_spoolman_preset
@@ -39,6 +42,7 @@ from backend.app.services.spoolman import (
     get_spoolman_client,
     init_spoolman_client,
 )
+from backend.app.services.spoolman_slot_sync import write_slot_results
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
@@ -220,6 +224,35 @@ async def disconnect_spoolman(
     return {"success": True, "message": "Disconnected from Spoolman"}
 
 
+def _empty_slot_may_be_unlinked(state, tray_data: dict) -> bool:
+    """The automatic pass's guards for a tray that reads empty (#3186): not while
+    the printer prints, where a runout reads empty, and not in a slot whose
+    presence bit says a spool is there. Its grace period is left out: the sync
+    was asked for.
+    """
+    if str(getattr(state, "state", "") or "").upper() in PrinterManager.ACTIVE_PRINT_STATES:
+        return False
+    return spool_present(tray_data) is not True
+
+
+async def _refresh_slot_preset(db: AsyncSession, spool: dict, tray, printer_id: int, ams_id: int) -> None:
+    """The slot's preset row follows the spool synced into it, as in the
+    automatic pass: the slot card reads that row ahead of the live tray, so it
+    kept the previous spool's preset name (SSA-2)."""
+    from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spoolman_spool
+
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db,
+        spoolman_spool=spool,
+        tray_info_idx=tray.tray_info_idx or "",
+        tray_sub_brands=tray.tray_sub_brands or "",
+        tray_type=tray.tray_type or "",
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray.tray_id,
+    )
+
+
 @router.post("/sync/{printer_id}", response_model=SyncResult)
 async def sync_printer_ams(
     printer_id: int,
@@ -314,30 +347,36 @@ async def sync_printer_ams(
             detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
         )
 
-    # Load inventory weights as fallback (when AMS MQTT data lacks remain values)
+    # Load inventory weights as fallback (when AMS MQTT data lacks remain values).
+    # Inert in Spoolman mode, as in the automatic pass (main.on_ams_change): the
+    # built-in slot rows are left over from before the switch, and a new Spoolman
+    # spool would be seeded with an unrelated weight (SM-C3).
     inv_weights: dict[tuple[int, int], float] = {}
-    try:
-        assign_result = await db.execute(
-            select(SpoolAssignment)
-            .options(selectinload(SpoolAssignment.spool))
-            .where(SpoolAssignment.printer_id == printer_id)
-        )
-        for assignment in assign_result.scalars().all():
-            spool = assignment.spool
-            if spool and spool.label_weight > 0:
-                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
-                inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
-    except Exception as e:
-        logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
+    if not await spoolman_owns_assignments(db):
+        try:
+            assign_result = await db.execute(
+                select(SpoolAssignment)
+                .options(selectinload(SpoolAssignment.spool))
+                .where(SpoolAssignment.printer_id == printer_id)
+            )
+            for assignment in assign_result.scalars().all():
+                spool = assignment.spool
+                if spool and spool.label_weight > 0:
+                    remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                    inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
+        except Exception as e:
+            logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
 
     # Load existing Spoolman slot assignments for the no-RFID fallback path
     spoolman_slot_map: dict[tuple[int, int], int] = {}
+    slot_map_read = False
     try:
         slot_result = await db.execute(
             select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
         )
         for slot in slot_result.scalars().all():
             spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+        slot_map_read = True
     except Exception as e:
         logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
 
@@ -358,7 +397,8 @@ async def sync_printer_ams(
             tray_id_raw = int(tray_data.get("id", 0))
             tray = client.parse_ams_tray(ams_id, tray_data)
             if not tray:
-                empty_slots.append((ams_id, tray_id_raw))
+                if _empty_slot_may_be_unlinked(state, tray_data):
+                    empty_slots.append((ams_id, tray_id_raw))
                 continue
 
             spool_tag = (
@@ -391,6 +431,7 @@ async def sync_printer_ams(
                         if not spool_exists:
                             cached_spools.append(sync_result)
                             logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                        await _refresh_slot_preset(db, sync_result, tray, printer_id, ams_id)
                     logger.info(
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                     )
@@ -419,29 +460,18 @@ async def sync_printer_ams(
                 logger.error(error_msg)
                 errors.append(error_msg)
 
-    # Persist slot assignment changes to the local table
+    # Persist slot assignment changes to the local table: only what changed,
+    # and only where the slot still holds what was read (SSA-3).
     if slot_changes or empty_slots:
         try:
-            for ams_id, tray_id, spool_id in slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for ams_id, tray_id in empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == printer_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            await write_slot_results(
+                db,
+                {(printer_id, a_id, t_id): spool_id for (a_id, t_id), spool_id in spoolman_slot_map.items()}
+                if slot_map_read
+                else None,
+                [(printer_id, a_id, t_id, spool_id) for a_id, t_id, spool_id in slot_changes],
+                [(printer_id, a_id, t_id) for a_id, t_id in empty_slots],
+            )
         except Exception as e:
             await db.rollback()
             logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
@@ -508,24 +538,28 @@ async def sync_all_printers(
 
     # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
     # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
+    # Inert in Spoolman mode, as in sync_printer_ams above (SM-C3).
     inventory_weights: dict[tuple[int, int, int], float] = {}
-    try:
-        assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
-        for assignment in assign_result.scalars().all():
-            spool = assignment.spool
-            if spool and spool.label_weight > 0:
-                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
-                inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
-    except Exception as e:
-        logger.debug("Could not load inventory assignments for weight fallback: %s", e)
+    if not await spoolman_owns_assignments(db):
+        try:
+            assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
+            for assignment in assign_result.scalars().all():
+                spool = assignment.spool
+                if spool and spool.label_weight > 0:
+                    remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                    inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
+        except Exception as e:
+            logger.debug("Could not load inventory assignments for weight fallback: %s", e)
 
     # Load all Spoolman slot assignments for the no-RFID fallback
     # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
     all_slot_map: dict[tuple[int, int, int], int] = {}
+    all_slot_map_read = False
     try:
         slot_result = await db.execute(select(SpoolmanSlotAssignment))
         for slot in slot_result.scalars().all():
             all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+        all_slot_map_read = True
     except Exception as e:
         logger.warning("Could not load Spoolman slot assignments: %s", e)
 
@@ -577,7 +611,8 @@ async def sync_all_printers(
                 tray_id_raw = int(tray_data.get("id", 0))
                 tray = client.parse_ams_tray(ams_id, tray_data)
                 if not tray:
-                    all_empty_slots.append((printer.id, ams_id, tray_id_raw))
+                    if _empty_slot_may_be_unlinked(state, tray_data):
+                        all_empty_slots.append((printer.id, ams_id, tray_id_raw))
                     continue
 
                 spool_tag = (
@@ -610,6 +645,7 @@ async def sync_all_printers(
                             if not spool_exists:
                                 cached_spools.append(sync_result)
                                 logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                            await _refresh_slot_preset(db, sync_result, tray, printer.id, ams_id)
                     elif spool_tag and not auto_add_unknown_rfid:
                         all_skipped.append(
                             SkippedSpool(
@@ -633,29 +669,11 @@ async def sync_all_printers(
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
-    # Persist slot assignment changes across all printers
+    # Persist slot assignment changes across all printers: only what changed,
+    # and only where the slot still holds what was read (SSA-3).
     if all_slot_changes or all_empty_slots:
         try:
-            for p_id, ams_id, tray_id, spool_id in all_slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": p_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for p_id, ams_id, tray_id in all_empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == p_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            await write_slot_results(db, all_slot_map if all_slot_map_read else None, all_slot_changes, all_empty_slots)
         except Exception as e:
             await db.rollback()
             logger.error("Error persisting Spoolman slot assignments: %s", e)
@@ -836,8 +854,8 @@ class LinkSpoolRequest(BaseModel):
     tray_uuid: str | None = None
     tag_uid: str | None = None
     printer_id: int | None = None
-    ams_id: int | None = None
-    tray_id: int | None = None
+    ams_id: SlotAmsId | None = None
+    tray_id: SlotTrayId | None = None
 
 
 @router.post("/spools/{spool_id}/link")
@@ -889,6 +907,18 @@ async def link_spool(
         if not printer_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Printer not found")
         printer_context = (request.printer_id, request.ams_id, request.tray_id)
+        # An archived spool on a slot would be charged the next print's usage;
+        # refused before anything is written, as the slot assignment does (SSA-8).
+        try:
+            linked = await client.get_spool(spool_id)
+        except SpoolmanNotFoundError:
+            raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
+        except SpoolmanClientError:
+            raise HTTPException(status_code=502, detail="Spoolman rejected the request")
+        except SpoolmanUnavailableError:
+            raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+        if linked.get("archived"):
+            raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
 
     try:
         # Spoolman 0.27+: a tray UUID also becomes a native tag of the spool. The
@@ -1134,7 +1164,9 @@ async def link_spool(
                     setting_id=effective_setting_id,
                 )
 
+                sent_cali_idx: int | None = None
                 if matching_kp and matching_kp.cali_idx is not None:
+                    sent_cali_idx = matching_kp.cali_idx
                     cali_filament_id = (
                         printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
                     ) or effective_tray_info_idx
@@ -1167,6 +1199,7 @@ async def link_spool(
                         live_tray = _find_tray_in_ams_data(ams_raw, a_id, t_id)
                     live_cali_idx = (live_tray or {}).get("cali_idx")
                     if live_cali_idx is not None and live_cali_idx >= 0:
+                        sent_cali_idx = live_cali_idx
                         mqtt_client.extrusion_cali_sel(
                             ams_id=a_id,
                             tray_id=t_id,
@@ -1175,6 +1208,18 @@ async def link_spool(
                             nozzle_diameter=nozzle_diameter,
                         )
 
+                # Read-back verification, as the assign paths register it
+                # (#2582); the K-profile only when one was sent.
+                mqtt_client.register_assignment_verification(
+                    ams_id=a_id,
+                    tray_id=t_id,
+                    tray_info_idx=effective_tray_info_idx,
+                    tray_color=tray_color,
+                    cali_idx=sent_cali_idx,
+                )
+                # Fresh telemetry for the check to compare against.
+                mqtt_client.request_status_update()
+
                 logger.info(
                     "Auto-configured AMS slot ams=%d tray=%d after linking Spoolman spool %d on printer %d",
                     a_id,

+ 114 - 13
backend/app/api/routes/spoolman_inventory.py

@@ -53,7 +53,7 @@ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
-from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
+from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import slot_unlink_grace
 from backend.app.services.location_service import (
@@ -61,10 +61,10 @@ from backend.app.services.location_service import (
     maybe_sync_spoolman_locations,
     resolve_spoolman_location_string,
 )
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.printer_manager import PrinterManager, printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
-from backend.app.services.spool_filament_preset import resolve_spoolman_preset
+from backend.app.services.spool_filament_preset import forget_spoolman_spool_own_preset, resolve_spoolman_preset
 from backend.app.services.spoolman import (
     SpoolmanClient,
     SpoolmanClientError,
@@ -76,6 +76,7 @@ from backend.app.services.spoolman import (
 from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot, is_slot_fallback_tag
 from backend.app.services.tag_conflict import tag_already_linked
 from backend.app.utils.color_utils import spoolman_color_hex
+from backend.app.utils.filament_finish import finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
@@ -455,9 +456,8 @@ class SpoolTagLinkRequest(BaseModel):
 class SpoolSlotAssignmentRequest(BaseModel):
     spoolman_spool_id: int = Field(..., gt=0)
     printer_id: int = Field(..., gt=0)
-    # ams_id 0–7 for physical AMS units; 255 = external/virtual spool extruder slot
-    ams_id: int = Field(..., ge=0, le=255)
-    tray_id: int = Field(..., ge=0, le=3)
+    ams_id: SlotAmsId
+    tray_id: SlotTrayId
 
 
 # ---------------------------------------------------------------------------
@@ -999,11 +999,40 @@ async def update_spool(
             new_extra[BAMBU_LAST_DRIED_HOURS_KEY] = json.dumps("")
         async with _translate_spoolman_errors():
             updated = await client.merge_spool_extra(spool_id, new_extra)
+        if sf_set or sfn_set:
+            forget_spoolman_spool_own_preset(spool_id)
 
     await ws_manager.broadcast({"type": "inventory_changed"})
     return _map_spoolman_spool(updated)
 
 
+async def _release_spool_slots(db: AsyncSession, spool_id: int, *, keep_tracked: bool) -> list[tuple[int, int, int]]:
+    """Delete a spool's slot rows; returns the freed (printer, ams, tray) slots.
+
+    ``keep_tracked`` keeps the rows of a printer with a print being tracked:
+    its usage is charged at completion by the slot's spool, which only the row
+    knows for a spool without a tag. ``release_archived_slot_rows`` frees them
+    once that print is reported. The caller commits, then announces.
+    """
+    from backend.app.models.active_print_spoolman import ActivePrintSpoolman
+
+    query = select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id)
+    if keep_tracked:
+        query = query.where(SpoolmanSlotAssignment.printer_id.not_in(select(ActivePrintSpoolman.printer_id)))
+    rows = (await db.execute(query)).scalars().all()
+    freed = [(r.printer_id, r.ams_id, r.tray_id) for r in rows]
+    for row in rows:
+        await db.delete(row)
+    return freed
+
+
+async def _announce_freed_slots(freed: list[tuple[int, int, int]]) -> None:
+    for printer_id, ams_id, tray_id in freed:
+        await ws_manager.broadcast(
+            {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
+        )
+
+
 async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None:
     """Drop the Bambuddy-side rows a deleted Spoolman spool leaves behind.
 
@@ -1031,8 +1060,12 @@ async def delete_spool(
     async with _translate_spoolman_errors():
         await client.delete_spool(spool_id)
     await _purge_local_rows_for_spool(db, spool_id)
+    # Its slots go too: a row left behind points tracking and the scheduler
+    # at an id Spoolman no longer has.
+    freed = await _release_spool_slots(db, spool_id, keep_tracked=False)
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"status": "deleted"}
 
 
@@ -1046,12 +1079,16 @@ async def archive_spool(
     client = await _get_client(db)
     async with _translate_spoolman_errors():
         spool = await client.set_spool_archived(spool_id, archived=True)
+    # An archived spool gives its slots up, as in the built-in inventory.
+    freed = await _release_spool_slots(db, spool_id, keep_tracked=True)
+    await db.commit()
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     try:
         mapped = _map_spoolman_spool(spool)
     except ValueError as exc:
         logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
-    await ws_manager.broadcast({"type": "inventory_changed"})
     return mapped
 
 
@@ -1157,12 +1194,14 @@ async def bulk_delete_spools(
     """Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
     client = await _get_client(db)
     deleted = 0
+    freed: list[tuple[int, int, int]] = []
     errors: list[dict] = []
     for sid in payload.ids:
         try:
             async with _translate_spoolman_errors():
                 await client.delete_spool(sid)
             await _purge_local_rows_for_spool(db, sid)
+            freed += await _release_spool_slots(db, sid, keep_tracked=False)
             deleted += 1
         except HTTPException as exc:
             errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
@@ -1172,6 +1211,7 @@ async def bulk_delete_spools(
     if deleted:
         await db.commit()
         await ws_manager.broadcast({"type": "inventory_changed"})
+        await _announce_freed_slots(freed)
     return {"deleted": deleted, "errors": errors}
 
 
@@ -1184,11 +1224,13 @@ async def bulk_archive_spools(
     """Archive every listed Spoolman spool. Per-spool failures are collected."""
     client = await _get_client(db)
     archived = 0
+    freed: list[tuple[int, int, int]] = []
     errors: list[dict] = []
     for sid in payload.ids:
         try:
             async with _translate_spoolman_errors():
                 await client.set_spool_archived(sid, archived=True)
+            freed += await _release_spool_slots(db, sid, keep_tracked=True)
             archived += 1
         except HTTPException as exc:
             errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
@@ -1196,7 +1238,9 @@ async def bulk_archive_spools(
             logger.exception("Spoolman bulk-archive failed for spool %s", sid)
             errors.append({"id": sid, "status": 500, "detail": str(exc)})
     if archived:
+        await db.commit()
         await ws_manager.broadcast({"type": "inventory_changed"})
+        await _announce_freed_slots(freed)
     return {"archived": archived, "errors": errors}
 
 
@@ -1235,8 +1279,10 @@ async def bulk_reset_spool_consumed_counter(
 
     Caller passes an explicit list of IDs — no "reset all" shortcut, since
     a typo on a wildcard would wipe the entire inventory's tracking.
-    Returns the count of spools successfully reset; individual failures are
-    logged but do not abort the batch.
+    Returns the count of spools reset and, like the other bulk actions, the
+    ones Spoolman refused in ``errors``: an individual failure does not abort
+    the batch, and a batch where every reset failed is not reported as a
+    success (FO-C8).
     """
     spool_ids = payload.get("spool_ids")
     if not isinstance(spool_ids, list) or not spool_ids:
@@ -1246,6 +1292,7 @@ async def bulk_reset_spool_consumed_counter(
 
     client = await _get_client(db)
     reset_count = 0
+    errors: list[dict] = []
     for spool_id in spool_ids:
         try:
             async with _translate_spoolman_errors():
@@ -1253,9 +1300,10 @@ async def bulk_reset_spool_consumed_counter(
             reset_count += 1
         except HTTPException as exc:
             logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
+            errors.append({"id": spool_id, "status": exc.status_code, "detail": exc.detail})
     if reset_count:
         await ws_manager.broadcast({"type": "inventory_changed"})
-    return {"reset": reset_count}
+    return {"reset": reset_count, "errors": errors}
 
 
 @router.patch("/spools/{spool_id}/weight")
@@ -1591,8 +1639,13 @@ async def sync_spoolman_ams_weights(
     # Only slots on printers the caller may see (#1727)
     assignments = [a for a in result.scalars().all() if printer_scope.allows(a.printer_id)]
 
+    from backend.app.services.usage_tracker import _active_sessions
+
     synced = 0
     skipped = 0
+    # A printing printer is left alone: its print's usage is charged when it
+    # completes, on top of whatever remaining weight the spool has by then.
+    printing = 0
 
     def _find_tray(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
         if not ams_data:
@@ -1619,7 +1672,8 @@ async def sync_spoolman_ams_weights(
             continue
 
         state = printer_manager.get_status(assignment.printer_id)
-        if not state or not state.raw_data:
+        # A disconnected printer keeps its last report, which is not a reading.
+        if not state or not state.raw_data or getattr(state, "connected", True) is False:
             logger.info(
                 "Spoolman AMS sync: printer %d not connected, skipping spool %d",
                 assignment.printer_id,
@@ -1628,6 +1682,21 @@ async def sync_spoolman_ams_weights(
             skipped += 1
             continue
 
+        # Until completion has charged the print its session stays open, also
+        # after the printer has reported the print finished.
+        if (
+            str(getattr(state, "state", "") or "").upper() in PrinterManager.ACTIVE_PRINT_STATES
+            or assignment.printer_id in _active_sessions
+        ):
+            logger.info(
+                "Spoolman AMS sync: printer %d is printing, skipping spool %d",
+                assignment.printer_id,
+                assignment.spoolman_spool_id,
+            )
+            skipped += 1
+            printing += 1
+            continue
+
         ams_raw = state.raw_data.get("ams", [])
         if isinstance(ams_raw, dict):
             ams_raw = ams_raw.get("ams", [])
@@ -1695,7 +1764,7 @@ async def sync_spoolman_ams_weights(
                 )
             skipped += 1
 
-    return {"synced": synced, "skipped": skipped}
+    return {"synced": synced, "skipped": skipped, "printing": printing}
 
 
 @router.post("/slot-assignments")
@@ -1722,6 +1791,10 @@ async def assign_spoolman_slot(
     # This prevents ghost rows pointing at non-existent spool IDs.
     async with _translate_spoolman_errors():
         spool = await client.get_spool(body.spoolman_spool_id)
+    # As the built-in inventory refuses it: an archived spool on a slot would
+    # be charged the next print's usage (SSA-8).
+    if spool.get("archived"):
+        raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
 
     # Spool confirmed in Spoolman — upsert into local slot-assignment table
     # assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)
@@ -1854,7 +1927,10 @@ async def assign_spoolman_slot(
             # preset (GFG96) rather than trading it for "PETG"'s GFG99.
             if not tray_info_idx:
                 tray_info_idx = (
-                    GENERIC_FILAMENT_IDS.get(material_upper)
+                    # The finish first, as in internal mode: a Silk spool
+                    # gets Generic PLA Silk.
+                    finish_generic_id(tray_type, f"{material} {subtype}")
+                    or GENERIC_FILAMENT_IDS.get(material_upper)
                     or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
                     or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                     or ""
@@ -1997,6 +2073,31 @@ async def assign_spoolman_slot(
                     body.spoolman_spool_id,
                 )
 
+            # Read-back verification, as the built-in assign registers it (#2582):
+            # the AMS pushes that follow confirm the tray took what was sent.
+            # Not for a slot the firmware calls empty, which echoes nothing
+            # back until a spool goes in (the built-in check, inventory.py).
+            from backend.app.api.routes.inventory import _find_tray_in_ams_data  # noqa: PLC0415
+            from backend.app.services.ams_slot_presence import spool_present  # noqa: PLC0415
+
+            live_tray = None
+            if state and state.raw_data:
+                ams_raw = state.raw_data.get("ams", [])
+                if isinstance(ams_raw, dict):
+                    ams_raw = ams_raw.get("ams", [])
+                live_tray = _find_tray_in_ams_data(ams_raw, body.ams_id, body.tray_id)
+            tray_state = (live_tray or {}).get("state")
+            if not (spool_present(live_tray) is not True and tray_state in (9, 10)):
+                mqtt_client.register_assignment_verification(
+                    ams_id=body.ams_id,
+                    tray_id=body.tray_id,
+                    tray_info_idx=effective_tray_info_idx,
+                    tray_color=tray_color,
+                    cali_idx=matching_kp.cali_idx if matching_kp and matching_kp.cali_idx is not None else -1,
+                )
+                # Fresh telemetry for the check to compare against.
+                mqtt_client.request_status_update()
+
             logger.info(
                 "Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",
                 body.ams_id,

+ 2 - 19
backend/app/api/routes/support.py

@@ -177,7 +177,7 @@ async def get_logs(
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
     level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
     search: str | None = Query(None, description="Search in message or logger name"),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Get recent application log entries with optional filtering."""
     entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
@@ -710,23 +710,6 @@ async def _collect_github_backup_info(db: AsyncSession) -> dict:
     }
 
 
-async def _check_url_reachable(url: str, timeout: float = 2.0) -> bool | None:
-    """Single HEAD/GET ping with a short timeout. Returns None if URL is empty."""
-    if not url or not url.strip():
-        return None
-    try:
-        import httpx
-
-        async with httpx.AsyncClient(timeout=timeout, verify=False) as client:  # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
-            r = await client.get(url, follow_redirects=False)
-            # Anything that returned a status code counts as reachable, even 404
-            # (the API server is up, just the path was wrong) — separates network
-            # failure from configuration mistakes for the user.
-            return r.status_code is not None
-    except Exception:
-        return False
-
-
 async def _fetch_slicer_health(url: str, timeout: float = 2.0) -> dict | None:
     """Fetch ``/health`` from a slicer sidecar and extract the CLI version.
 
@@ -1492,7 +1475,7 @@ async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
 
 @router.get("/bundle")
 async def generate_support_bundle(
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Generate a support bundle ZIP file for issue reporting."""
     # Check if debug logging is enabled and collect sensitive values for redaction

+ 18 - 3
backend/app/api/routes/system.py

@@ -13,11 +13,12 @@ from fastapi import APIRouter, Depends
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
 from backend.app.core.local_config import read_local_toml, read_ntp_gate
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.print_log import PrintLogEntry
@@ -403,12 +404,17 @@ async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict
 async def get_system_info(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get comprehensive system information."""
 
     # Database stats
     archive_count = await db.scalar(select(func.count(PrintArchive.id)))
-    printer_count = await db.scalar(select(func.count(Printer.id)))
+    # Printers are counted and listed as the caller's printer list shows them.
+    printer_count_query = select(func.count(Printer.id))
+    if (printer_clause := printer_scope.where_strict(Printer.id)) is not None:
+        printer_count_query = printer_count_query.where(printer_clause)
+    printer_count = await db.scalar(printer_count_query)
     filament_count = await db.scalar(select(func.count(Filament.id)))
     project_count = await db.scalar(select(func.count(Project.id)))
     smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
@@ -441,6 +447,8 @@ async def get_system_info(
     # Connected printers
     connected_printers = []
     for printer_id, client in printer_manager._clients.items():
+        if not printer_scope.allows(printer_id):
+            continue
         state = client.state
         if state and state.connected:
             # Get printer name and model from database
@@ -596,6 +604,7 @@ async def get_storage_usage(
 async def get_system_health(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Scan the recent application log against the known-issue catalog.
 
@@ -603,7 +612,13 @@ async def get_system_health(
     Sample lines are sanitized before they leave the process.
     """
     sensitive_strings = await collect_sensitive_strings(db)
-    return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+    result = await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+    # A log line can be about any printer; a caller limited to some gets the
+    # findings without them.
+    if not printer_scope.is_unrestricted:
+        for finding in result.findings:
+            finding.sample = ""
+    return result
 
 
 @router.get("/db-pool")

+ 108 - 29
backend/app/api/routes/updates.py

@@ -5,6 +5,7 @@ import logging
 import os
 import re
 import shutil
+import signal
 import sys
 import time
 
@@ -25,6 +26,53 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/updates", tags=["updates"])
 
 # Global state for update progress
+# How long one update step may run. Generous, for slow links and a Raspberry
+# Pi, but finite: a step that hung (a stalled git fetch or npm install) left the
+# status at "downloading" or "installing" for good, and every retry answered
+# "Update already in progress" until Bambuddy was restarted.
+GIT_STEP_TIMEOUT_SECONDS = 300
+INSTALL_STEP_TIMEOUT_SECONDS = 900
+TERMINATE_GRACE_SECONDS = 5
+
+
+class _StepTimedOut(Exception):
+    def __init__(self, step: str, seconds: float) -> None:
+        super().__init__(f"{step} timed out after {seconds:.0f} s")
+        self.step = step
+
+
+async def _communicate(process, timeout: float, step: str) -> tuple[bytes, bytes]:
+    """``process.communicate()`` with a deadline; at it the step is stopped."""
+    try:
+        return await asyncio.wait_for(process.communicate(), timeout)
+    except TimeoutError:
+        # Asked first: git removes its lock files on SIGTERM, and a lock left
+        # by SIGKILL would make every retry fail. Killed if it does not go.
+        for sig, grace in ((signal.SIGTERM, TERMINATE_GRACE_SECONDS), (signal.SIGKILL, 10)):
+            _signal_step(process, sig)
+            try:
+                await asyncio.wait_for(process.wait(), grace)
+                break
+            except (TimeoutError, ProcessLookupError):
+                if sig == signal.SIGKILL:
+                    logger.warning("%s did not exit after being killed", step)
+        raise _StepTimedOut(step, timeout) from None
+
+
+def _signal_step(process, sig) -> None:
+    """The whole process group where the step was started in its own session
+    (npm and pip start children of their own); otherwise the process itself."""
+    try:
+        if hasattr(os, "killpg") and isinstance(process.pid, int) and os.getpgid(process.pid) == process.pid:
+            os.killpg(process.pid, sig)
+        elif sig == signal.SIGTERM:
+            process.terminate()
+        else:
+            process.kill()
+    except (ProcessLookupError, PermissionError, OSError):
+        pass
+
+
 _update_status = {
     "status": "idle",  # idle, checking, downloading, installing, complete, error
     "progress": 0,
@@ -324,8 +372,8 @@ async def _origin_points_at_repo(git_path: str, git_config: list[str], app_dir,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
-        stdout, _ = await process.communicate()
-    except (OSError, asyncio.CancelledError):
+        stdout, _ = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git remote get-url")
+    except (OSError, asyncio.CancelledError, _StepTimedOut):
         # Fail closed: let the caller go through the rewrite branch if we
         # can't even invoke git. The unconditional set-url is the safer
         # fallback, only mildly destructive.
@@ -753,7 +801,7 @@ async def _perform_update(target_ref: str):
                 stdout=asyncio.subprocess.PIPE,
                 stderr=asyncio.subprocess.PIPE,
             )
-            await process.communicate()
+            await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git remote set-url")
 
         _update_status = {
             "status": "downloading",
@@ -787,7 +835,7 @@ async def _perform_update(target_ref: str):
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
-        stdout, stderr = await process.communicate()
+        stdout, stderr = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git fetch")
 
         if process.returncode != 0:
             error_msg = stderr.decode() if stderr else "Git fetch failed"
@@ -823,7 +871,7 @@ async def _perform_update(target_ref: str):
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
-        stdout, stderr = await process.communicate()
+        stdout, stderr = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git reset")
 
         if process.returncode != 0:
             error_msg = stderr.decode() if stderr else "Git reset failed"
@@ -858,8 +906,9 @@ async def _perform_update(target_ref: str):
             cwd=str(app_dir),
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
+            start_new_session=True,
         )
-        stdout, stderr = await process.communicate()
+        stdout, stderr = await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "pip install")
 
         if process.returncode != 0:
             logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
@@ -876,29 +925,36 @@ async def _perform_update(target_ref: str):
                 "error": None,
             }
 
-            # npm install
-            process = await asyncio.create_subprocess_exec(
-                npm_path,
-                "install",
-                cwd=str(frontend_dir),
-                stdout=asyncio.subprocess.PIPE,
-                stderr=asyncio.subprocess.PIPE,
-            )
-            await process.communicate()
-
-            # npm run build
-            process = await asyncio.create_subprocess_exec(
-                npm_path,
-                "run",
-                "build",
-                cwd=str(frontend_dir),
-                stdout=asyncio.subprocess.PIPE,
-                stderr=asyncio.subprocess.PIPE,
-            )
-            stdout, stderr = await process.communicate()
-
-            if process.returncode != 0:
-                logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
+            # Optional: the release ships pre-built static files, so a build
+            # that fails or runs out of time leaves those in place.
+            try:
+                # npm install
+                process = await asyncio.create_subprocess_exec(
+                    npm_path,
+                    "install",
+                    cwd=str(frontend_dir),
+                    stdout=asyncio.subprocess.PIPE,
+                    stderr=asyncio.subprocess.PIPE,
+                    start_new_session=True,
+                )
+                await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "npm install")
+
+                # npm run build
+                process = await asyncio.create_subprocess_exec(
+                    npm_path,
+                    "run",
+                    "build",
+                    cwd=str(frontend_dir),
+                    stdout=asyncio.subprocess.PIPE,
+                    stderr=asyncio.subprocess.PIPE,
+                    start_new_session=True,
+                )
+                stdout, stderr = await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "npm run build")
+
+                if process.returncode != 0:
+                    logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
+            except _StepTimedOut as exc:
+                logger.warning("%s; keeping the pre-built frontend", exc)
         else:
             logger.info("npm not found or frontend dir missing - using pre-built static files")
 
@@ -911,6 +967,29 @@ async def _perform_update(target_ref: str):
 
         logger.info("Update completed successfully")
 
+    except _StepTimedOut as exc:
+        logger.error("Update failed: %s", exc)
+        if exc.step == "pip install":
+            # git reset has already moved the code: it must not be started on
+            # dependencies that were never installed.
+            advice = (
+                "The new code is in place but its dependencies were not installed. "
+                "Check for updates and run the update again before restarting."
+            )
+        elif exc.step == "git reset":
+            # Stopped while the files were being replaced: some may be new.
+            advice = (
+                "The new code may be only partly in place. "
+                "Check for updates and run the update again before restarting."
+            )
+        else:
+            advice = "Nothing was installed. Check for updates and run the update again."
+        _update_status = {
+            "status": "error",
+            "progress": 0,
+            "message": f"Update failed: {exc.step} took too long",
+            "error": f"{exc}. {advice}",
+        }
     except Exception as e:
         logger.error("Update failed: %s", e)
         _update_status = {

+ 203 - 12
backend/app/api/routes/users.py

@@ -4,7 +4,7 @@ from typing import Annotated
 import jwt as _jwt
 from fastapi import APIRouter, Depends, HTTPException, Query, status
 from fastapi.security import HTTPAuthorizationCredentials
-from sqlalchemy import delete, func, select
+from sqlalchemy import delete, func, select, update as sql_update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -26,13 +26,14 @@ from backend.app.core.permissions import Permission
 from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
-from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.group import Group
 from backend.app.models.library import LibraryFile
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.oidc_provider import UserOIDCLink
 from backend.app.models.print_batch import PrintBatch
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -53,7 +54,10 @@ from backend.app.services.email_service import (
     send_email,
 )
 from backend.app.services.finance_defaults import ensure_user_finance_defaults
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slice_dispatch import slice_dispatch
 from backend.app.utils.library_paths import remove_library_photos_dir
+from backend.app.utils.local_time import utcnow_naive
 
 router = APIRouter(prefix="/users", tags=["users"])
 
@@ -92,6 +96,24 @@ async def list_users(
     return [_user_to_response(user) for user in users]
 
 
+async def drop_pending_tokens(db: AsyncSession, username: str) -> None:
+    """Delete the one-time tokens pending under a username that is going away.
+
+    Reset links, 2FA and OIDC steps, connect codes, camera, WebSocket and slicer
+    tokens are looked up by username, so they would work for whoever is given
+    the name next. The logout list (revoked_jti) stays: dropping an entry would
+    make the logged-out token valid again.
+    """
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            # Both sides in SQL: SQLite's lower() changes only ASCII letters, so
+            # it never equals Python's lower() of a name with an umlaut.
+            func.lower(AuthEphemeralToken.username) == func.lower(username),
+            AuthEphemeralToken.token_type != "revoked_jti",
+        )
+    )
+
+
 @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def create_user(
@@ -254,6 +276,8 @@ async def update_user(
     db: AsyncSession = Depends(get_db),
 ):
     """Update a user."""
+    close_sockets = False
+    deactivated_keys: list[int] = []
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
@@ -303,9 +327,40 @@ async def update_user(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Username already exists",
             )
+        if user_data.username != user.username:
+            # The name passes on: a deleted user's still-valid JWTs under the new
+            # name must not sign this user in, and what is pending under the old
+            # one must not reach whoever is given it next. This user's own
+            # sessions carry the old name and end anyway. A change of case counts
+            # too: SQLite compares only ASCII letters without case, so 'Ärger'
+            # and 'ärger' are two names there.
+            await drop_pending_tokens(db, user.username)
+            user.password_changed_at = utcnow_naive()
+            close_sockets = True
         user.username = user_data.username
 
-    if user_data.email is not None:
+    if user_data.email is not None and not user_data.email.strip():
+        # Empty removes the email; null leaves it as it is.
+        if user.email:
+            rows = await db.execute(
+                select(Settings.key, Settings.value).where(
+                    Settings.key.in_(["advanced_auth_enabled", f"user_{user.id}_email_2fa_enabled"])
+                )
+            )
+            flags = {key: (value or "").lower() == "true" for key, value in rows.all()}
+            if flags.get("advanced_auth_enabled"):
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail="Email is required when advanced authentication is enabled",
+                )
+            # Their sign-in codes go to this address.
+            if flags.get(f"user_{user.id}_email_2fa_enabled"):
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail="Email is required while the user has email two-factor authentication on",
+                )
+            user.email = None
+    elif user_data.email is not None:
         # Check if new email already exists (case-insensitive)
         existing_email = await db.execute(
             select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
@@ -324,6 +379,11 @@ async def update_user(
                 detail="Cannot set password for LDAP users",
             )
         user.password_hash = get_password_hash(user_data.password)
+        # As a reset: sessions and pending tokens of the old password end (an
+        # admin setting a password is often taking an account back).
+        await drop_pending_tokens(db, user.username)
+        user.password_changed_at = utcnow_naive()
+        close_sockets = True
 
     if user_data.role is not None:
         if user_data.role not in ["admin", "user"]:
@@ -334,6 +394,20 @@ async def update_user(
         user.role = user_data.role
 
     if user_data.is_active is not None:
+        if user.is_active and not user_data.is_active:
+            # Deactivated: their printers go at the scope refresh below, but
+            # events of no printer (inventory, SpoolBuddy tags, announcements)
+            # would still reach their open dashboards, and their tokens would
+            # open new ones. The same for their API keys' (a kiosk), which
+            # REST already refuses for a deactivated owner.
+            from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+            await drop_pending_tokens(db, user.username)
+            deactivated_keys = list(
+                (await db.execute(select(APIKey.id).where(APIKey.user_id == user_id))).scalars().all()
+            )
+            await drop_api_key_tokens(db, deactivated_keys)
+            close_sockets = True
         user.is_active = user_data.is_active
 
     # Handle group assignments
@@ -350,6 +424,14 @@ async def update_user(
     await ensure_user_finance_defaults(db, user)
 
     await db.commit()
+    if deactivated_keys:
+        await ws_manager.close_api_keys(deactivated_keys)
+    if close_sockets:
+        # Open dashboards are not signed in again. After a rename they keep the
+        # name they connected with and look their printers up by it on every
+        # scope refresh (the next user given it); after a password set they are
+        # whoever held the account before.
+        await ws_manager.close_user(user_id)
     if user_data.group_ids is not None or user_data.role is not None or user_data.is_active is not None:
         # Groups, admin role and deactivation all change which printers the
         # user's open dashboards may hear about (#1727)
@@ -460,7 +542,92 @@ async def delete_user(
         doomed_library_file_ids = list(
             (await db.execute(select(LibraryFile.id).where(LibraryFile.created_by_id == user_id))).scalars().all()
         )
-        # Delete all items created by this user
+        # Delete all items created by this user. The print log stays, without
+        # the archives it pointed at; archives of other users printed from
+        # these library files lose that link (both SET NULL on PostgreSQL).
+        from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
+
+        await delete_dependent_variants(db, doomed_library_file_ids)
+        await release_queue_references(db, doomed_library_file_ids)
+        # A print still running keeps what it needs to be billed when it
+        # ends: its queue row and the user's archive it prints are detached
+        # (no user), as without delete_items, rather than deleted. Deleted, the
+        # completion found no queue row and charged the archive's creator
+        # instead (for a reprint someone else, who never printed it), or found
+        # no archive and billed nothing, its reservation held for good.
+        from sqlalchemy import and_, or_
+
+        from backend.app.models.finance import BudgetReservation
+
+        running_rows = (
+            await db.execute(
+                select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
+                    PrintQueueItem.created_by_id == user_id, PrintQueueItem.status == "printing"
+                )
+            )
+        ).all()
+        running_items = [row.id for row in running_rows]
+        running_archives = [row.archive_id for row in running_rows if row.archive_id is not None]
+        running_archives += (
+            (
+                await db.execute(
+                    select(PrintArchive.id).where(
+                        PrintArchive.created_by_id == user_id, PrintArchive.status == "printing"
+                    )
+                )
+            )
+            .scalars()
+            .all()
+        )
+        # Also one another user is printing (a reprint of this user's archive,
+        # dispatched before the printer's start marks the archive printing):
+        # deleted, it took their queue row with it and their print went unbilled.
+        own_archives = select(PrintArchive.id).where(PrintArchive.created_by_id == user_id)
+        running_archives += (
+            (
+                await db.execute(
+                    select(PrintQueueItem.archive_id).where(
+                        PrintQueueItem.status == "printing", PrintQueueItem.archive_id.in_(own_archives)
+                    )
+                )
+            )
+            .scalars()
+            .all()
+        )
+        if running_items:
+            await db.execute(
+                sql_update(PrintQueueItem).where(PrintQueueItem.id.in_(running_items)).values(created_by_id=None)
+            )
+        if running_archives:
+            await db.execute(
+                sql_update(PrintArchive)
+                .where(PrintArchive.id.in_(running_archives), PrintArchive.created_by_id == user_id)
+                .values(created_by_id=None)
+            )
+
+        # Budget held for the queue items and archives deleted here: no print
+        # completion can settle it any more (it finds no archive), and an
+        # active reservation held the cost centre's budget, and kept the cost
+        # centre from being deleted, for good. Nothing is charged for those:
+        # they go, as the admin chose. (A running print's was detached above.)
+        await db.execute(
+            sql_update(BudgetReservation)
+            .where(
+                BudgetReservation.status == "active",
+                or_(
+                    and_(
+                        BudgetReservation.source_type == "print_queue",
+                        BudgetReservation.source_id.in_(
+                            select(PrintQueueItem.id).where(PrintQueueItem.created_by_id == user_id)
+                        ),
+                    ),
+                    BudgetReservation.print_archive_id.in_(
+                        select(PrintArchive.id).where(PrintArchive.created_by_id == user_id)
+                    ),
+                ),
+            )
+            .values(status="released", released_at=datetime.now(timezone.utc))
+        )
         await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
         await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
         await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
@@ -495,6 +662,10 @@ async def delete_user(
         await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
         await db.execute(update(PrintBatch).where(PrintBatch.created_by_id == user_id).values(created_by_id=None))
 
+    # The print log keeps who started each run as long as the user exists, and
+    # outlives them either way (ON DELETE SET NULL, which SQLite doesn't apply).
+    await db.execute(sql_update(PrintLogEntry).where(PrintLogEntry.created_by_id == user_id).values(created_by_id=None))
+
     # Drop API keys owned by this user. The model declares ON DELETE CASCADE
     # so Postgres handles this automatically, but SQLite ships with FK
     # enforcement off (the project's existing pattern — same reason the
@@ -504,6 +675,10 @@ async def delete_user(
     # silently degrading the keys to anonymous (and locking them out of
     # /cloud/* — but the rest of the API would still accept them, which is
     # exactly the orphan-key state the CASCADE was meant to prevent).
+    from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+    key_ids = (await db.execute(select(APIKey.id).where(APIKey.user_id == user_id))).scalars().all()
+    await drop_api_key_tokens(db, key_ids)
     await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
 
     # Drop OIDC links, MFA state, and long-lived camera-stream tokens
@@ -523,21 +698,30 @@ async def delete_user(
     await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
 
     # Connected apps, same SQLite/FK pattern. A leftover consent row would
-    # skip the consent screen for whoever is next given this user id, and a
-    # code issued in the last minute is keyed by username, not id.
+    # skip the consent screen for whoever is next given this user id.
     from sqlalchemy import update as _update
 
     await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.user_id == user_id))
-    await db.execute(
-        delete(AuthEphemeralToken).where(
-            AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
-            func.lower(AuthEphemeralToken.username) == user.username.lower(),
-        )
-    )
+    await drop_pending_tokens(db, user.username)
     await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
 
+    # Email 2FA is a settings key named after the id, which no foreign key covers.
+    await db.execute(delete(Settings).where(Settings.key == f"user_{user_id}_email_2fa_enabled"))
+
     await db.delete(user)
     await db.commit()
+    # What still runs for them must not be credited to whoever gets the id next
+    # (SQLite reuses it): a print, a slice, an open dashboard.
+    printer_manager.forget_user(user_id)
+    from backend.app.main import forget_print_creator
+
+    forget_print_creator(user_id)
+    slice_dispatch.forget_owner(user_id)
+    await ws_manager.close_user(user_id)
+    await ws_manager.close_api_keys(key_ids)
+    from backend.app.api.routes.notifications import forget_app_senders
+
+    forget_app_senders(key_ids, [user_id])
     await ws_manager.refresh_printer_scopes()
 
     for file_id in doomed_library_file_ids:
@@ -596,7 +780,14 @@ async def change_own_password(
     # Update password
     user.password_hash = get_password_hash(password_data.new_password)
     user.password_changed_at = datetime.now(timezone.utc)  # M-R7-B: invalidate all prior JWTs
+    # The WebSocket and camera tokens too: reusable for an hour, they would
+    # reconnect a closed dashboard straight away.
+    await drop_pending_tokens(db, user.username)
     await db.commit()
+    # Open dashboards are never signed in again: one someone else had open on
+    # the account would keep receiving its printers. This tab signs in again
+    # (the route revokes its JWT anyway).
+    await ws_manager.close_user(user.id)
 
     # L-R6-A: Password verified successfully — reset the failure counter
     from backend.app.api.routes.mfa import clear_failed_attempts

+ 90 - 50
backend/app/api/routes/virtual_printers.py

@@ -1,15 +1,19 @@
 import logging
+import os
+from pathlib import Path
 
 from fastapi import APIRouter, Depends
 from fastapi.responses import JSONResponse
 from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.user import User
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.virtual_printer import VPDiagnosticResult
 
 # Imported at module scope so tests can patch
@@ -45,7 +49,8 @@ class VirtualPrinterCreate(BaseModel):
     remote_interface_ip: str | None = None
 
 
-class VirtualPrinterUpdate(BaseModel):
+class VirtualPrinterUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "virtual_printers"
     name: str | None = None
     enabled: bool | None = None
     mode: str | None = None
@@ -78,6 +83,14 @@ def _resolve_printer_model(printer_model: str | None) -> str | None:
     return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
 
 
+def _target_missing(target_printer_id: int) -> JSONResponse:
+    return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
+
+
+def _vp_missing() -> JSONResponse:
+    return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+
 async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
     """Convert VirtualPrinter model to response dict.
 
@@ -125,6 +138,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
 async def list_virtual_printers(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List all virtual printers with status."""
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -135,6 +149,10 @@ async def list_virtual_printers(
 
     printers = []
     for vp in vps:
+        # One bridging to a printer the caller can't use is left out: its
+        # status names that printer.
+        if not printer_scope.allows(vp.target_printer_id):
+            continue
         instance = virtual_printer_manager.get_instance(vp.id)
         status = instance.get_status() if instance else {"running": False, "pending_files": 0}
         printers.append(await _vp_to_dict(vp, db, status))
@@ -150,6 +168,7 @@ async def create_virtual_printer(
     body: VirtualPrinterCreate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Create a new virtual printer."""
     from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
@@ -189,15 +208,13 @@ async def create_virtual_printer(
 
     # Validate proxy target printer exists
     target_printer = None
-    if body.target_printer_id:
+    if body.target_printer_id is not None:
         from backend.app.models.printer import Printer
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         target_printer = result.scalar_one_or_none()
-        if not target_printer:
-            return JSONResponse(
-                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
-            )
+        if not target_printer or not printer_scope.allows(target_printer.id):
+            return _target_missing(body.target_printer_id)
 
     # Validate bind_ip uniqueness (against all enabled VPs)
     if body.bind_ip:
@@ -315,6 +332,7 @@ async def diagnose_virtual_printer(
     vp_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Run setup diagnostics for a virtual printer.
 
@@ -327,8 +345,8 @@ async def diagnose_virtual_printer(
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
     instance = virtual_printer_manager.get_instance(vp.id)
     return await run_vp_diagnostic(vp, instance)
@@ -339,6 +357,7 @@ async def get_virtual_printer(
     vp_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get a single virtual printer with status."""
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -346,8 +365,8 @@ async def get_virtual_printer(
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
     instance = virtual_printer_manager.get_instance(vp.id)
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
@@ -361,6 +380,7 @@ async def update_virtual_printer(
     body: VirtualPrinterUpdate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update a virtual printer."""
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -368,8 +388,9 @@ async def update_virtual_printer(
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    # One bridging to a printer the caller can't use is not theirs to change.
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
     # Redact the access code before logging — model_dump otherwise includes
     # the plaintext value at DEBUG, violating the project no-secrets-in-logs
@@ -415,10 +436,8 @@ async def update_virtual_printer(
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         target_printer = result.scalar_one_or_none()
-        if not target_printer:
-            return JSONResponse(
-                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
-            )
+        if not target_printer or not printer_scope.allows(target_printer.id):
+            return _target_missing(body.target_printer_id)
         vp.target_printer_id = body.target_printer_id
         # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
         if body.model is None and vp.mode == "proxy" and target_printer.model:
@@ -491,7 +510,11 @@ async def update_virtual_printer(
             )
             return JSONResponse(
                 status_code=400,
-                content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
+                content={
+                    "detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"
+                    if printer_scope.allows(conflict.target_printer_id)
+                    else f"Bind IP {vp.bind_ip} is already in use"
+                },
             )
         if effective_mode == "proxy":
             if not vp.target_printer_id:
@@ -534,57 +557,74 @@ async def update_virtual_printer(
     return await _vp_to_dict(vp, db, status)
 
 
-@router.delete("/{vp_id}")
-async def delete_virtual_printer(
-    vp_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-):
-    """Delete a virtual printer."""
+async def drop_virtual_printer(db: AsyncSession, vp_id: int) -> Path:
+    """Delete a virtual printer's row and discard its pending uploads.
+
+    Its instance must be stopped first, if one runs. The caller commits, then
+    calls remove_upload_dir with what this returns. *db* is a session or a
+    connection.
+    """
     from sqlalchemy import delete as sql_delete
 
+    from backend.app.models.pending_upload import PendingUpload
     from backend.app.models.virtual_printer import VirtualPrinter
     from backend.app.services.virtual_printer import virtual_printer_manager
 
-    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
-    vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
-
-    vp_name = vp.name
-
-    # Stop instance if running
-    await virtual_printer_manager.remove_instance(vp_id)
-
+    upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
     # Mark any PendingUpload rows that referenced this VP's upload_dir as
     # discarded — without this the rows live on as phantom entries in
     # /pending-uploads/ pointing at file paths that no longer exist, and
     # the user only learns they're orphaned by trying to archive one and
     # getting a flip-to-discarded on file-missing.
-    upload_prefix = str(virtual_printer_manager._base_dir / "uploads" / str(vp_id))
     try:
-        from backend.app.models.pending_upload import PendingUpload
-
-        stale = await db.execute(select(PendingUpload).where(PendingUpload.file_path.startswith(upload_prefix)))
-        for pending in stale.scalars().all():
-            pending.status = "discarded"
-        await db.flush()
+        await db.execute(
+            update(PendingUpload)
+            # The folder, not every path that starts with its name (uploads/12 for 1).
+            .where(PendingUpload.file_path.startswith(f"{upload_dir}{os.sep}", autoescape=True))
+            .values(status="discarded")
+        )
     except Exception as e:
         logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
-
-    # Delete from DB
     await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
-    await db.commit()
+    return upload_dir
 
-    # Remove the on-disk upload directory after the DB commit succeeds, so
-    # a crash between commit and rmtree only leaves orphan files (vs orphan
-    # rows pointing at a now-missing tree).
-    upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
+
+def remove_upload_dir(upload_dir: Path) -> None:
+    """Remove a deleted virtual printer's upload directory.
+
+    After the DB commit succeeds, so a crash between commit and rmtree only
+    leaves orphan files (vs orphan rows pointing at a now-missing tree).
+    """
     if upload_dir.exists():
         import shutil
 
         shutil.rmtree(upload_dir, ignore_errors=True)
 
+
+@router.delete("/{vp_id}")
+async def delete_virtual_printer(
+    vp_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Delete a virtual printer."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
+
+    vp_name = vp.name
+
+    # Stop instance if running
+    await virtual_printer_manager.remove_instance(vp_id)
+    upload_dir = await drop_virtual_printer(db, vp_id)
+    await db.commit()
+    remove_upload_dir(upload_dir)
+
     logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
 
     # Resync remaining services

+ 40 - 16
backend/app/api/routes/webhook.py

@@ -5,7 +5,9 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes.print_queue import _assert_can_queue_archive, saved_slicer_ams_mapping
 from backend.app.core.auth import (
+    ApiKeyActor,
     api_key_printer_scope,
     check_webhook_permission,
     ensure_api_key_printer_access,
@@ -21,8 +23,12 @@ from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import HMSErrorResponse, hms_error_responses
 from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
+from backend.app.services.print_control import stop_print_by_user
+from backend.app.services.print_defaults import default_print_options
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.queue_announcements import announce_queue_jobs_added
 from backend.app.services.queue_position import next_queue_position
+from backend.app.utils.local_time import to_naive_utc
 
 logger = logging.getLogger(__name__)
 
@@ -111,11 +117,15 @@ async def webhook_add_to_queue(
     await check_webhook_permission(db, api_key, "queue")
     await ensure_api_key_printer_access(db, api_key, data.printer_id)
 
-    # Verify archive exists
+    # Verify archive exists and passes the same source gate as POST /queue/,
+    # with the key acting for its owner within its printers.
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
     archive = result.scalar_one_or_none()
     if not archive:
         raise HTTPException(status_code=404, detail="Archive not found")
+    # Auth off: no owner to act for, as on POST /queue/; the key's printers still apply.
+    actor = ApiKeyActor(api_key, await resolve_apikey_owner(db, api_key)) if await is_auth_enabled(db) else None
+    _assert_can_queue_archive(archive, actor, await api_key_printer_scope(db, api_key))
 
     # Verify printer exists
     result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
@@ -133,8 +143,9 @@ async def webhook_add_to_queue(
         from datetime import datetime
 
         try:
-            scheduled_time = datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00"))
-        except ValueError:
+            # Stored as naive UTC: an offset is converted, not dropped.
+            scheduled_time = to_naive_utc(datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00")))
+        except (ValueError, OverflowError):
             raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
 
     # Create queue item
@@ -155,10 +166,15 @@ async def webhook_add_to_queue(
         created_by_id=api_key.user_id,
         # Waits for someone to start it unless the owner may print without review (#1620)
         manual_start=await is_auth_enabled(db) and queue_review_required_for(await resolve_apikey_owner(db, api_key)),
+        # No print options in the request: the install's defaults.
+        **await default_print_options(db),
+        # The slicer's slots, as a reprint from the UI reuses them
+        ams_mapping=saved_slicer_ams_mapping(archive, data.printer_id),
     )
     db.add(queue_item)
-    await db.flush()
+    await db.commit()
     await db.refresh(queue_item)
+    await announce_queue_jobs_added(db, [queue_item.id])
 
     return QueueAddResponse(
         id=queue_item.id,
@@ -235,6 +251,22 @@ async def webhook_start_print(
     return {"message": "Print started", "queue_item_id": queue_item.id}
 
 
+# States in which a print can be stopped, as with the Stop button on the
+# printer card: printing or paused.
+_STOPPABLE_STATES = ("RUNNING", "PAUSE")
+
+
+def _stop_current_print(printer_id: int) -> None:
+    """Stop the print on the user's behalf (cancel and stop are the same command)."""
+    try:
+        sent = stop_print_by_user(printer_id)
+    except Exception as e:
+        logger.error("Failed to stop print on printer %s: %s", printer_id, e)
+        raise HTTPException(status_code=500, detail=str(e))
+    if not sent:
+        raise HTTPException(status_code=503, detail="Printer not connected")
+
+
 @router.post("/printer/{printer_id}/stop")
 async def webhook_stop_print(
     printer_id: int,
@@ -255,14 +287,10 @@ async def webhook_stop_print(
     if not status or not status.connected:
         raise HTTPException(status_code=503, detail="Printer not connected")
 
-    if status.state != "RUNNING":
+    if status.state not in _STOPPABLE_STATES:
         raise HTTPException(status_code=409, detail="No print in progress")
 
-    try:
-        await printer_manager.stop_print(printer_id)
-    except Exception as e:
-        logger.error("Failed to stop print: %s", e)
-        raise HTTPException(status_code=500, detail=str(e))
+    _stop_current_print(printer_id)
 
     return {"message": "Print stopped"}
 
@@ -285,14 +313,10 @@ async def webhook_cancel_print(
     if not status or not status.connected:
         raise HTTPException(status_code=503, detail="Printer not connected")
 
-    if status.state not in ["RUNNING", "PAUSE"]:
+    if status.state not in _STOPPABLE_STATES:
         raise HTTPException(status_code=409, detail="No print to cancel")
 
-    try:
-        await printer_manager.cancel_print(printer_id)
-    except Exception as e:
-        logger.error("Failed to cancel print: %s", e)
-        raise HTTPException(status_code=500, detail=str(e))
+    _stop_current_print(printer_id)
 
     return {"message": "Print cancelled"}
 

+ 116 - 16
backend/app/api/routes/websocket.py

@@ -21,7 +21,13 @@ import logging
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from sqlalchemy import select
 
-from backend.app.core.auth import is_auth_enabled, principal_printer_scope, verify_websocket_token_principal
+from backend.app.core.auth import (
+    is_auth_enabled,
+    principal_may_connect,
+    principal_printer_scope,
+    usable_api_key_prefix,
+    verify_websocket_token_principal,
+)
 from backend.app.core.database import async_session
 from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 from backend.app.core.websocket import ws_manager
@@ -38,6 +44,60 @@ router = APIRouter()
 _WS_CLOSE_UNAUTHORIZED = 4401
 
 
+async def _user_id_of(username: str) -> int | None:
+    """The id of the user with this name, or None (also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            row = await db.execute(select(User.id).where(User.username == username))
+            return row.scalar_one_or_none()
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket principal resolve failed for %s", username, exc_info=True)
+        return None
+
+
+async def _api_key_prefix(api_key_id: int) -> str | None:
+    """The key's prefix, which tells it from a later key given its id; None when
+    the key is gone, disabled or expired (also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            return await usable_api_key_prefix(db, api_key_id)
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket API key lookup failed for %s", api_key_id, exc_info=True)
+        return None
+
+
+async def _still_may_connect(principal: str | None, api_key_id: int | None) -> bool:
+    """websocket:connect still held (False also when it can't be read): a group
+    or key change that took it away while the socket joined closed nothing."""
+    try:
+        async with async_session() as db:
+            return await principal_may_connect(db, principal, api_key_id)
+    except Exception:  # SEC-AUTH-EXC: a failed read refuses the socket (fail-closed)
+        logger.warning("WebSocket permission re-check failed", exc_info=True)
+        return False
+
+
+async def _auth_turned_on() -> bool:
+    """Whether authentication is on now (True also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            return await is_auth_enabled(db)
+    except Exception:  # SEC-AUTH-EXC: a failed read refuses the socket (fail-closed)
+        logger.warning("WebSocket auth re-check failed", exc_info=True)
+        return True
+
+
+async def _token_principal(token: str | None):
+    """The token's principal again, or None (also when it can't be read)."""
+    if not token:
+        return None
+    try:
+        return await verify_websocket_token_principal(token)
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket token re-check failed", exc_info=True)
+        return None
+
+
 @router.websocket("/ws")
 async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(default=None)) -> None:
     """WebSocket endpoint for real-time updates.
@@ -86,20 +146,57 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
         # with every printer.
         try:
             async with async_session() as db:
+                may_connect = await principal_may_connect(db, principal, api_key_id)
                 printer_scope = await principal_printer_scope(db, principal, api_key_id)
         except Exception:  # SEC-AUTH-EXC: scope lookup failure → refuse connect (fail-closed)
             logger.error("WebSocket printer scope lookup failed; refusing connection", exc_info=True)
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
+        if not may_connect:
+            # The token was minted while its principal held websocket:connect;
+            # a group or key change has taken it away since.
+            logger.info("WebSocket connect refused: websocket:connect no longer held")
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
 
     # Token verified (or auth disabled); now safe to admit the connection.
     logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
+    # Resolve principal username → User.id once at connect so
+    # ``ws_manager.broadcast_to_user()`` can filter without re-querying
+    # per message. Auth-disabled path keeps None (broadcast_to_user fans
+    # out to all when target is None — matches the legacy single-user
+    # toast behaviour). API-keyed principal is empty string → None.
+    #
+    # A user renamed or deleted since the token was checked is refused: the
+    # socket would keep the old name, and every scope refresh would give it the
+    # printers of the next user given that name. Done before connect(), so the
+    # socket carries its id from the moment it is listed, and checked again
+    # after: a rename or delete commits before it closes the user's sockets by
+    # id, so one landing in between is seen by the second check, and one after
+    # it finds this socket listed with its id.
+    #
+    # Same for an API key deleted since: its scope is looked up by key id on
+    # every refresh, and SQLite gives the id to the next key created.
+    principal_user_id: int | None = None
+    if principal:
+        principal_user_id = await _user_id_of(principal)
+        if principal_user_id is None:
+            logger.info("WebSocket connect refused: user %s is gone", principal)
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
+    api_key_prefix = await _api_key_prefix(api_key_id) if api_key_id is not None else None
+    if api_key_id is not None and api_key_prefix is None:
+        logger.info("WebSocket connect refused: API key %s is gone, disabled or expired", api_key_id)
+        await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+        return
     # Stamped before connect() puts the socket in the broadcast list, so no
     # broadcast can reach it unfiltered (ws_manager refuses printer-bound
     # messages to a socket without a scope anyway).
     websocket.state.bambuddy_printer_scope = printer_scope
     websocket.state.bambuddy_scope_principal = (principal, api_key_id)
-    await ws_manager.connect(websocket)
+    websocket.state.bambuddy_principal_user_id = principal_user_id
+    # A scope refresh closes the socket if a different key has the id by then.
+    websocket.state.bambuddy_api_key_prefix = api_key_prefix
     # Stash on connection state for any future per-message permission
     # logic; today the message handlers are read-only and only respond
     # to the requesting socket, so the stash is informational. The
@@ -107,20 +204,23 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
     # ``broadcast_to_principal()`` helper can filter on it without
     # touching every call site.
     websocket.state.bambuddy_principal = principal
-    # Resolve principal username → User.id once at connect so
-    # ``ws_manager.broadcast_to_user()`` can filter without re-querying
-    # per message. Auth-disabled path keeps None (broadcast_to_user fans
-    # out to all when target is None — matches the legacy single-user
-    # toast behaviour). API-keyed principal is empty string → None.
-    principal_user_id: int | None = None
-    if principal:
-        try:
-            async with async_session() as db:
-                row = await db.execute(select(User.id).where(User.username == principal))
-                principal_user_id = row.scalar_one_or_none()
-        except Exception:  # SEC-AUTH-EXC: resolution failure is non-fatal — degrades to no per-user routing
-            logger.warning("WebSocket principal resolve failed for %s", principal, exc_info=True)
-    websocket.state.bambuddy_principal_user_id = principal_user_id
+    await ws_manager.connect(websocket)
+    # And the token still there: whatever ends a session (password change or
+    # reset, 2FA removal, deactivation) drops it before closing the user's
+    # sockets, so one landing in between is seen here. Admitted with auth off,
+    # auth must still be off: turning it on closes the anonymous sockets, which
+    # this one may have joined just after.
+    if (
+        (principal and await _user_id_of(principal) != principal_user_id)
+        or (api_key_id is not None and await _api_key_prefix(api_key_id) != api_key_prefix)
+        or (auth_required and await _token_principal(token) != (principal, api_key_id))
+        or (auth_required and not await _still_may_connect(principal, api_key_id))
+        or (not auth_required and await _auth_turned_on())
+    ):
+        logger.info("WebSocket connect refused: its session ended while it connected")
+        await ws_manager.disconnect(websocket)
+        await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+        return
     logger.info("WebSocket client connected")
 
     try:

+ 13 - 4
backend/app/cli.py

@@ -53,10 +53,6 @@ async def kiosk_bootstrap(
                 f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
             )
 
-        if existing:
-            await db.delete(existing)
-            await db.flush()
-
         full_key, key_hash, key_prefix = generate_api_key()
         row = APIKey(
             name=name,
@@ -83,6 +79,19 @@ async def kiosk_bootstrap(
             expires_at=None,
         )
         db.add(row)
+        if existing:
+            from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+            # The new key is added before the old one goes, so it gets a new id:
+            # SQLite would give it the old one's, and the old (perhaps leaked)
+            # key's links and open dashboards would take its printers, all of
+            # them even if an admin had limited or disabled the old key. This
+            # runs outside the server, so those dashboards stay open with the
+            # scope they had until the server next refreshes scopes, which
+            # closes sockets whose key is gone.
+            await db.flush()
+            await drop_api_key_tokens(db, [existing.id])
+            await db.delete(existing)
 
         # Mark first-run setup as completed so the kiosk URL loads directly
         # instead of being force-redirected to /setup by AuthContext. Without

+ 429 - 226
backend/app/core/auth.py

@@ -5,7 +5,7 @@ import os
 import secrets
 import time
 from contextvars import ContextVar
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
@@ -14,6 +14,7 @@ from fastapi import Depends, Header, HTTPException, status
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 from jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
+from passlib.exc import PasswordSizeError
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -31,6 +32,7 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
+from backend.app.utils.local_time import utcnow_naive
 
 logger = logging.getLogger(__name__)
 
@@ -151,11 +153,11 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     # can_manage_library — file-manager scope (upload/rename/delete library
     # entries + MakerWorld import which downloads files into the library).
     # OWN and ALL ownership variants map to the same scope so the
-    # `require_ownership_permission` checker (which gates on `all_perm`)
-    # passes the API key through. This matches `can_queue` and the
-    # archives/inventory scopes — API keys have no per-row ownership identity
-    # (line 1663), so splitting OWN/ALL across allowlist/denylist made the
-    # whole library curation surface unreachable for API keys (#1832).
+    # `require_ownership_permission` checker passes the API key through and
+    # lets its owner's OWN/ALL permission decide the rows. This matches
+    # `can_queue` and the archives/inventory scopes; splitting OWN/ALL across
+    # allowlist/denylist made the whole library curation surface unreachable
+    # for API keys (#1832).
     # LIBRARY_PURGE stays admin-only as a genuinely destructive op that
     # bypasses the soft-delete window.
     Permission.LIBRARY_UPLOAD: "can_manage_library",
@@ -191,10 +193,9 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     # can_manage_archives — print-history curation. Carved out of the admin
     # denylist so automations can prune old prints via API key (#1888): the
     # archive delete/update routes gate on
-    # ``require_ownership_permission(ARCHIVES_*_ALL, ARCHIVES_*_OWN)``, which
-    # resolves the ALL permission for API keys (no per-row ownership identity,
-    # same as can_queue / can_manage_library), so OWN and ALL map to the same
-    # scope. ARCHIVES_PURGE stays admin-only (see denylist) as a genuinely
+    # ``require_ownership_permission(ARCHIVES_*_ALL, ARCHIVES_*_OWN)``, where
+    # the key's owner's OWN/ALL permission decides the rows (same as can_queue
+    # / can_manage_library), so OWN and ALL map to the same scope. ARCHIVES_PURGE stays admin-only (see denylist) as a genuinely
     # destructive op that drops the stats contribution, mirroring LIBRARY_PURGE.
     # ARCHIVES_REPRINT_* stays under can_queue (it enqueues a print).
     Permission.ARCHIVES_CREATE: "can_manage_archives",
@@ -440,7 +441,7 @@ def _check_apikey_permissions(
     ``resolve_apikey_owner``.
 
     By default ALL requested permissions must pass (mirrors
-    ``require_permission`` / ``require_permission_if_auth_enabled``).
+    ``require_permission_if_auth_enabled``).
     When ``require_any=True``, only one needs to pass (mirrors
     ``require_any_permission_if_auth_enabled``).
     """
@@ -839,6 +840,79 @@ async def verify_slicer_download_token(
         return True
 
 
+async def token_outlived_its_session(db: AsyncSession, username: str | None, minted_at: datetime | None) -> bool:
+    """Whether a token named after a user was minted before that user's session began.
+
+    password_changed_at marks it: set for a new user, and on a rename, a
+    password change or reset, a 2FA removal or a restore. A token from before
+    belongs to an earlier holder of the name or an ended session; deletes and
+    renames drop those, but one minted while they commit would survive.
+
+    A token naming a user that does not exist counts as outlived: its callers
+    look the user up again on their own, and a user created with the name in
+    between would be served. Compared to the microsecond (both are written by
+    Python): every user is committed before any token is minted for it, so
+    there is nothing to round for, unlike a JWT's whole-second iat.
+    """
+    if not username:
+        return False
+    if minted_at is None:
+        return True
+    changed = (
+        await db.execute(select(User.password_changed_at).where(func.lower(User.username) == func.lower(username)))
+    ).first()
+    if changed is None:
+        return True  # no such user
+    session_start = changed[0]
+    if session_start is None:
+        return False  # no session start recorded (an old row the start-up backfill has not reached)
+    return minted_at.replace(tzinfo=None) < session_start.replace(tzinfo=None)
+
+
+def _minting_session_start() -> datetime | None:
+    """When the session of the request minting a token began, as a created_at.
+
+    A token minted by a signed-in user counts from their JWT's issue time, not
+    from when its row is written: a password change, reset or 2FA removal that
+    commits in between (a stolen JWT minting in a loop) would otherwise leave a
+    token dated after it. The end of the iat second, as _is_token_fresh treats
+    a JWT from the second the session began as fresh. None outside a request
+    signed in with a JWT (API keys, auth off, sign-in steps).
+    """
+    cached = _authenticated_user.get()
+    if cached is None:
+        return None
+    try:
+        iat = jwt.decode(cached[0], SECRET_KEY, algorithms=[ALGORITHM]).get("iat")
+    except JWTError:
+        return None
+    if iat is None:
+        return None
+    return datetime.fromtimestamp(int(iat), tz=timezone.utc).replace(tzinfo=None) + timedelta(microseconds=999999)
+
+
+def session_dated(username: str | None) -> dict:
+    """created_at for a token a signed-in user mints for themself (see
+    _minting_session_start); nothing when the request's session is someone
+    else's (an admin minting for a user) or there is none."""
+    cached = _authenticated_user.get()
+    if not username or cached is None or cached[1].username.lower() != username.lower():
+        return {}
+    start = _minting_session_start()
+    return {"created_at": start} if start is not None else {}
+
+
+def _api_key_still_the_minter(current_prefix: str | None, minted_prefix: str | None) -> bool:
+    """Whether a key-minted token's key is still the one that minted it.
+
+    The prefix is recorded at mint (in ``nonce``): SQLite gives a deleted key's
+    id to the next key created, and a token minted while the key was deleted
+    would otherwise work with the new key's printers. Tokens from before the
+    prefix was recorded have none and pass (they expire within the hour).
+    """
+    return minted_prefix is None or current_prefix == minted_prefix
+
+
 # --- Camera stream tokens ---
 # Reusable tokens for camera stream/snapshot endpoints loaded via <img>/<video>
 # tags (these cannot send Authorization headers).  Unlike slicer tokens they are
@@ -852,7 +926,9 @@ async def verify_slicer_download_token(
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 
 
-async def create_camera_stream_token(username: str | None = None, api_key_id: int | None = None) -> str:
+async def create_camera_stream_token(
+    username: str | None = None, api_key_id: int | None = None, api_key_prefix: str | None = None
+) -> str:
     """Create a reusable token for camera stream/snapshot access.
 
     Records who minted it -- ``username`` for a user, ``api_key_id`` for an
@@ -876,7 +952,9 @@ async def create_camera_stream_token(username: str | None = None, api_key_id: in
                 token_type="camera_stream",
                 username=username or "",
                 api_key_id=api_key_id,
+                nonce=api_key_prefix if api_key_id is not None else None,
                 expires_at=expires_at,
+                **session_dated(username),
             )
         )
         await db.commit()
@@ -886,7 +964,9 @@ async def create_camera_stream_token(username: str | None = None, api_key_id: in
 WEBSOCKET_TOKEN_EXPIRE_MINUTES = 60
 
 
-async def create_websocket_token(username: str | None, api_key_id: int | None = None) -> str:
+async def create_websocket_token(
+    username: str | None, api_key_id: int | None = None, api_key_prefix: str | None = None
+) -> str:
     """Create a short-lived token for ``/api/v1/ws`` connections.
 
     Mirrors the camera-stream-token pattern: opaque random string stored
@@ -919,7 +999,9 @@ async def create_websocket_token(username: str | None, api_key_id: int | None =
                 token_type="websocket",
                 username=username or "",
                 api_key_id=api_key_id,
+                nonce=api_key_prefix if api_key_id is not None else None,
                 expires_at=expires_at,
+                **session_dated(username),
             )
         )
         await db.commit()
@@ -945,23 +1027,83 @@ async def verify_websocket_token_principal(token: str) -> tuple[str, int | None]
             )
         )
         row = result.scalar_one_or_none()
-        if row is None:
+        if row is None or await token_outlived_its_session(db, row.username, row.created_at):
+            return None
+        if row.api_key_id is not None and not _api_key_still_the_minter(
+            await usable_api_key_prefix(db, row.api_key_id), row.nonce
+        ):
             return None
         return row.username or "", row.api_key_id
 
 
-async def verify_websocket_token(token: str) -> str | None:
-    """Verify a WebSocket connect token.
+_NO_PRINTERS = PrinterScope(frozenset())
+
+
+async def usable_api_key_prefix(db: AsyncSession, api_key_id: int) -> str | None:
+    """The prefix of an API key that may still be used, else None.
 
-    Returns the recorded ``username`` (possibly ``""`` for API-key
-    callers, never ``None`` on success) when the token is valid, or
-    ``None`` when it is missing / expired / unknown.
+    None for a key that is gone, disabled or expired. The prefix tells a key
+    from a later one given its id (SQLite reuses ids), so an open WebSocket can
+    tell whether its key is still the one it connected with.
     """
-    principal = await verify_websocket_token_principal(token)
-    return None if principal is None else principal[0]
+    api_key = (await db.execute(select(APIKey).where(APIKey.id == api_key_id))).scalar_one_or_none()
+    if api_key is None or not api_key.enabled:
+        return None
+    if api_key.expires_at is not None:
+        expires = api_key.expires_at
+        if expires.tzinfo is None:
+            expires = expires.replace(tzinfo=timezone.utc)
+        if expires < datetime.now(timezone.utc):
+            return None
+    return api_key.key_prefix
 
 
-_NO_PRINTERS = PrinterScope(frozenset())
+async def principal_holds(
+    db: AsyncSession, username: str | None, api_key_id: int | None, permission: Permission
+) -> bool:
+    """Whether a token's principal still holds the permission minting it needed.
+
+    A group change can take it away while the token is still valid (an hour,
+    or months for a long-lived one) and a connection or stream is open. A key
+    is checked as at the mint (its scope flags and its owner's permissions); a
+    user must exist, be active and hold it.
+    """
+    if api_key_id is not None:
+        api_key = (await db.execute(select(APIKey).where(APIKey.id == api_key_id))).scalar_one_or_none()
+        if api_key is None:
+            return False
+        try:
+            await authorize_api_key(db, api_key, [permission.value])
+        except HTTPException:
+            return False
+        return True
+    if username:
+        user = await get_user_by_username(db, username)
+        return user is not None and user.is_active and user.has_permission(permission.value)
+    return False
+
+
+async def user_id_holds(
+    db: AsyncSession, user_id: int, permission: Permission, minted_at: datetime | None = None
+) -> bool:
+    """principal_holds for the owner of a long-lived token, named by id.
+
+    With ``minted_at`` (the token's created_at), a user created after the token
+    is not its owner: SQLite gives a deleted user's id to the next user, and a
+    token minted while the user was deleted would stream with the new user's
+    printers for up to a year. Both created_at are set by the database, so they
+    share one clock.
+    """
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+    user = result.scalar_one_or_none()
+    if user is None or not user.is_active or not user.has_permission(permission.value):
+        return False
+    return minted_at is None or user.created_at is None or minted_at >= user.created_at
+
+
+async def principal_may_connect(db: AsyncSession, username: str | None, api_key_id: int | None) -> bool:
+    """Whether a WebSocket token's principal still holds websocket:connect."""
+    return await principal_holds(db, username, api_key_id, Permission.WEBSOCKET_CONNECT)
 
 
 async def principal_printer_scope(db: AsyncSession, username: str | None, api_key_id: int | None) -> PrinterScope:
@@ -993,14 +1135,35 @@ async def principal_printer_scope(db: AsyncSession, username: str | None, api_ke
     return _NO_PRINTERS
 
 
-async def verify_camera_stream_token(token: str) -> PrinterScope | None:
-    """Verify a camera stream token (reusable -- does not consume it).
+@dataclass(frozen=True)
+class StreamOpener:
+    """Who opened a camera stream, and on what authority; re-checked while it runs.
+
+    A live view runs for hours on a token checked once. Its opener is a user
+    (a 60-minute token minted with their session), an API key, a long-lived
+    token (Cam Wall, overlay, Home Assistant), or nobody while auth was off.
+    ``started_at`` is the token's created_at -- when the session that minted it
+    began -- not when the stream opened: a session that ended in between must
+    still end the stream.
+    """
 
-    Returns the printer scope of whoever minted it (#1727), or None when the
-    token is invalid. Tries the ephemeral 60-minute token first (the common,
-    browser-bound case) and falls through to long-lived tokens (#1108) for HA
-    / kiosk integrations that paste a token once and expect it to keep
-    working for days; those carry their owner's scope.
+    auth_on: bool
+    username: str | None = None
+    api_key_id: int | None = None
+    api_key_prefix: str | None = None
+    long_lived_id: int | None = None
+    long_lived_user_id: int | None = None
+    long_lived_prefix: str | None = None
+    started_at: datetime = field(default_factory=utcnow_naive)
+
+
+async def camera_stream_access(token: str) -> tuple[PrinterScope, StreamOpener] | None:
+    """Verify a camera stream token: the minter's printer scope and who it is.
+
+    Tries the ephemeral 60-minute token first (the common, browser-bound case)
+    and falls through to long-lived tokens (#1108) for HA / kiosk integrations
+    that paste a token once and expect it to keep working for days; those
+    carry their owner's scope. None when the token is invalid.
     """
     now = datetime.now(timezone.utc)
     async with async_session() as db:
@@ -1012,17 +1175,110 @@ async def verify_camera_stream_token(token: str) -> PrinterScope | None:
             )
         )
         row = result.scalar_one_or_none()
+        if row is not None and await token_outlived_its_session(db, row.username, row.created_at):
+            return None
+        if (
+            row is not None
+            and row.api_key_id is not None
+            and not _api_key_still_the_minter(await usable_api_key_prefix(db, row.api_key_id), row.nonce)
+        ):
+            return None
         if row is not None:
-            return await principal_printer_scope(db, row.username, row.api_key_id)
+            # Minting it needed camera:view; a group or key change can take it away.
+            if not await principal_holds(db, row.username, row.api_key_id, Permission.CAMERA_VIEW):
+                return None
+            scope = await principal_printer_scope(db, row.username, row.api_key_id)
+            prefix = await usable_api_key_prefix(db, row.api_key_id) if row.api_key_id is not None else None
+            # When the minting session began (see _minting_session_start), or
+            # when the token was minted for one that is not a user's session.
+            minted = row.created_at.replace(tzinfo=None)
+            return scope, StreamOpener(
+                auth_on=True,
+                username=row.username or None,
+                api_key_id=row.api_key_id,
+                api_key_prefix=prefix,
+                started_at=minted,
+            )
 
         # Long-lived path. Imported lazily so the auth module stays importable
         # at startup before the long_lived_tokens model is registered.
         from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
-        if record is None:
+        if record is None or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW, record.created_at):
             return None
-        return await resolve_user_id_printer_scope(db, record.user_id)
+        scope = await resolve_user_id_printer_scope(db, record.user_id)
+        return scope, StreamOpener(
+            auth_on=True,
+            long_lived_id=record.id,
+            long_lived_user_id=record.user_id,
+            long_lived_prefix=record.lookup_prefix,
+        )
+
+
+async def verify_camera_stream_token(token: str) -> PrinterScope | None:
+    """Verify a camera stream token (reusable -- does not consume it).
+
+    Returns the printer scope of whoever minted it (#1727), or None when the
+    token is invalid. See camera_stream_access.
+    """
+    access = await camera_stream_access(token)
+    return access[0] if access is not None else None
+
+
+async def stream_opener_still_allowed(opener: StreamOpener, printer_id: int) -> bool:
+    """Whether a stream's opener may still watch this printer. Raises when it
+    can't be read: the stream decides how long it may run unchecked.
+
+    A user's sessions must not have ended since the stream started (password
+    change or reset, 2FA removal, restore), and they must still exist and be
+    active; a key must still be usable and the same key; a long-lived token
+    must not be revoked or expired; a stream opened while auth was off ends
+    once it is on. In every case camera:view must still be held (by the
+    token's owner for a long-lived one) and the printer must still be in scope.
+    """
+    from backend.app.models.long_lived_token import LongLivedToken
+    from backend.app.services.long_lived_tokens import _is_expired
+
+    try:
+        async with async_session() as db:
+            auth_on = await is_auth_enabled(db)
+            if not opener.auth_on:
+                return not auth_on
+            if not auth_on:
+                return True
+            if opener.long_lived_id is not None:
+                record = await db.get(LongLivedToken, opener.long_lived_id)
+                if (
+                    record is None
+                    or record.revoked_at is not None
+                    or record.user_id != opener.long_lived_user_id
+                    or record.lookup_prefix != opener.long_lived_prefix
+                    or _is_expired(record, datetime.now(timezone.utc))
+                    or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW, record.created_at)
+                ):
+                    return False
+                scope = await resolve_user_id_printer_scope(db, record.user_id)
+            elif opener.api_key_id is not None:
+                if await usable_api_key_prefix(db, opener.api_key_id) != opener.api_key_prefix:
+                    return False
+                if not await principal_holds(db, None, opener.api_key_id, Permission.CAMERA_VIEW):
+                    return False
+                scope = await principal_printer_scope(db, None, opener.api_key_id)
+            elif opener.username:
+                user = await get_user_by_username(db, opener.username)
+                if user is None or not user.is_active or not user.has_permission(Permission.CAMERA_VIEW.value):
+                    return False
+                changed = user.password_changed_at
+                if changed is not None and changed.replace(tzinfo=None) > opener.started_at:
+                    return False
+                scope = await principal_printer_scope(db, opener.username, None)
+            else:
+                return False
+            return scope.allows(printer_id)
+    except Exception:  # SEC-AUTH-EXC: logged and re-raised, never an answer
+        logger.warning("Camera stream re-check failed", exc_info=True)
+        raise
 
 
 async def verify_camwall_token(token: str) -> PrinterScope | None:
@@ -1042,7 +1298,8 @@ async def verify_camwall_token(token: str) -> PrinterScope | None:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope="camwall")
-        if record is None:
+        # Minting it needed camera:view; a group change can take it away.
+        if record is None or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW, record.created_at):
             return None
         return await resolve_user_id_printer_scope(db, record.user_id)
 
@@ -1063,7 +1320,8 @@ async def verify_overlay_token(token: str) -> PrinterScope | None:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope="overlay")
-        if record is None:
+        # Minting it needed camera:view; a group change can take it away.
+        if record is None or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW, record.created_at):
             return None
         return await resolve_user_id_printer_scope(db, record.user_id)
 
@@ -1115,6 +1373,7 @@ async def create_media_token(username: str | None) -> str:
                 token_type="media",
                 username=username or "",
                 expires_at=expires_at,
+                **session_dated(username),
             )
         )
         await db.commit()
@@ -1146,7 +1405,7 @@ async def verify_media_token(token: str) -> str | None:
             )
         )
         row = result.scalar_one_or_none()
-        if row is None:
+        if row is None or await token_outlived_its_session(db, row.username, row.created_at):
             return None
         return row.username or ""
 
@@ -1402,18 +1661,35 @@ _validated_api_key: ContextVar[tuple[str, APIKey] | None] = ContextVar("_validat
 _authenticated_user: ContextVar[tuple[str, User] | None] = ContextVar("_authenticated_user", default=None)
 
 
+def _api_key_check_unavailable(error: Exception) -> HTTPException:
+    """The 503 for a key that couldn't be checked (database, pool), logged.
+
+    Not 401: the key may well be valid, and clients treat 401 "Invalid API key"
+    as final (the web interface clears its sign-in on it). Still fail-closed:
+    the request is refused either way.
+    """
+    logger.error("API key check failed: %s", error, exc_info=error)
+    return HTTPException(
+        status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+        detail="Authentication service temporarily unavailable",
+    )
+
+
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
 
+    Raises a 503 HTTPException when the key can't be checked at all (database
+    or pool error): that is not an invalid key, and must not read as one.
+
     L-1: Pre-filter by key_prefix (first 8 chars) before running pbkdf2 so only
     O(1) candidate rows are hashed instead of the full key table.  The prefix is
     not secret (it is shown in the admin UI), so this does not reduce security.
     """
+    # key_prefix is stored as "<first-8-chars>..." (e.g. "bb_Abc12...").
+    # Matching on the first 8 chars of the submitted key reduces the scan to
+    # at most one row in practice (2^40 collision space for 5 base64 chars).
+    key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
     try:
-        # key_prefix is stored as "<first-8-chars>..." (e.g. "bb_Abc12...").
-        # Matching on the first 8 chars of the submitted key reduces the scan to
-        # at most one row in practice (2^40 collision space for 5 base64 chars).
-        key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
         result = await db.execute(
             select(APIKey).where(
                 APIKey.enabled.is_(True),
@@ -1423,23 +1699,38 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
             )
         )
         api_keys = result.scalars().all()
+    except Exception as e:  # SEC-AUTH-EXC: key lookup failed -> raises 503, request refused (no key returned)
+        raise _api_key_check_unavailable(e) from e
 
-        for api_key in api_keys:
-            if verify_password(api_key_value, api_key.key_hash):
-                # Check expiration
-                if api_key.expires_at:
-                    expires = api_key.expires_at
-                    if expires.tzinfo is None:
-                        expires = expires.replace(tzinfo=timezone.utc)
-                    if expires < datetime.now(timezone.utc):
-                        return None  # Expired
-                # Update last_used timestamp
-                api_key.last_used = datetime.now(timezone.utc)
-                await db.commit()
-                _validated_api_key.set((api_key_value, api_key))
-                return api_key
-    except Exception as e:  # SEC-AUTH-EXC: validation failure returns None; every caller treats None as "invalid key" → 401 (fail-closed)
-        logger.warning("API key validation error: %s", e)
+    for api_key in api_keys:
+        try:
+            matches = verify_password(api_key_value, api_key.key_hash)
+        except PasswordSizeError:
+            # The submitted value is too long to hash (passlib's limit): the
+            # client's input, not this row, and certainly not a match.
+            continue
+        except Exception as e:  # SEC-AUTH-EXC: unverifiable hash counts as no match -> None -> 401
+            # An unreadable stored hash can't vouch for anything: not a match.
+            logger.warning("API key %s has an unreadable hash: %s", api_key.id, e)
+            continue
+        if not matches:
+            continue
+        # Check expiration
+        if api_key.expires_at:
+            expires = api_key.expires_at
+            if expires.tzinfo is None:
+                expires = expires.replace(tzinfo=timezone.utc)
+            if expires < datetime.now(timezone.utc):
+                return None  # Expired
+        # Update last_used timestamp
+        api_key.last_used = datetime.now(timezone.utc)
+        try:
+            await db.commit()
+        except Exception as e:  # SEC-AUTH-EXC: last_used write failed -> rollback + raise 503, key not returned
+            await db.rollback()
+            raise _api_key_check_unavailable(e) from e
+        _validated_api_key.set((api_key_value, api_key))
+        return api_key
     return None
 
 
@@ -1524,6 +1815,8 @@ async def get_current_user(
             )
         if not _is_token_fresh(iat, user):
             raise credentials_exception
+        # What this request mints counts from this session (_minting_session_start)
+        _authenticated_user.set((token, user))
         return user
 
 
@@ -1615,6 +1908,8 @@ async def require_auth_if_enabled(
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                 )
+            # What this request mints counts from this session (_minting_session_start)
+            _authenticated_user.set((credentials.credentials, user))
             return user
 
         # No credentials provided
@@ -1625,20 +1920,6 @@ async def require_auth_if_enabled(
         )
 
 
-def require_role(required_role: str):
-    """Dependency factory for role-based access control."""
-
-    async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
-        if current_user.role != required_role:
-            raise HTTPException(
-                status_code=status.HTTP_403_FORBIDDEN,
-                detail=f"Requires {required_role} role",
-            )
-        return current_user
-
-    return role_checker
-
-
 def require_admin_if_auth_enabled():
     """Dependency factory that requires admin role if auth is enabled.
 
@@ -1755,9 +2036,11 @@ async def get_api_key(
     x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     db: AsyncSession = Depends(get_db),
 ) -> APIKey:
-    """Get and validate API key from request headers.
+    """Get and validate API key from request headers (the webhook routes).
 
-    Checks both 'Authorization: Bearer <key>' and 'X-API-Key: <key>' headers.
+    Checks both 'Authorization: Bearer <key>' and 'X-API-Key: <key>' headers;
+    the key itself is checked by _validate_api_key. An expired key is an
+    invalid key, as on every other route.
     """
     api_key_value = None
     if x_api_key:
@@ -1771,41 +2054,15 @@ async def get_api_key(
             detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
         )
 
-    # Pre-filter by key_prefix to avoid O(n) pbkdf2 hashes across all enabled keys.
-    key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
-    result = await db.execute(
-        select(APIKey).where(
-            APIKey.enabled.is_(True),
-            APIKey.key_prefix.like(
-                key_lookup.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%",
-                escape="\\",
-            ),
+    # The same check as every other API-key route: a key that can't be checked
+    # (database error) is a 503 raised in there, not an invalid key.
+    api_key = await _validate_api_key(db, api_key_value)
+    if api_key is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Invalid API key",
         )
-    )
-    api_keys = result.scalars().all()
-
-    for api_key in api_keys:
-        # Check if key matches (verify against hash)
-        if verify_password(api_key_value, api_key.key_hash):
-            # Check expiration
-            if api_key.expires_at:
-                expires = api_key.expires_at
-                if expires.tzinfo is None:
-                    expires = expires.replace(tzinfo=timezone.utc)
-                if expires < datetime.now(timezone.utc):
-                    raise HTTPException(
-                        status_code=status.HTTP_401_UNAUTHORIZED,
-                        detail="API key has expired",
-                    )
-            # Update last_used timestamp
-            api_key.last_used = datetime.now(timezone.utc)
-            await db.commit()
-            return api_key
-
-    raise HTTPException(
-        status_code=status.HTTP_401_UNAUTHORIZED,
-        detail="Invalid API key",
-    )
+    return api_key
 
 
 async def caller_is_api_key(
@@ -1902,9 +2159,11 @@ async def validated_api_key_from_request(
     """Return the validated API key carried by a request, if any.
 
     Permission dependencies intentionally return ``None`` for API-key callers so
-    routes do not mistake a key for a user identity. Printer-bound routes still
-    need the key row to enforce ``printer_ids`` after the normal scope/owner
-    permission gate has run. This helper recognizes both supported transports.
+    routes do not mistake a key for a user identity (the ownership dependency
+    returns an ``ApiKeyActor`` for a key whose owner may act on own rows only).
+    Printer-bound routes still need the key row to enforce ``printer_ids`` after
+    the normal scope/owner permission gate has run. This helper recognizes both
+    supported transports.
     """
 
     candidate = x_api_key
@@ -1980,7 +2239,7 @@ async def get_printer_scope_if_auth_enabled(
 RequestPrinterScope = Depends(get_printer_scope_if_auth_enabled)
 
 
-def queue_review_required_for(user: User | None) -> bool:
+def queue_review_required_for(user: User | ApiKeyActor | None) -> bool:
     """Whether jobs ``user`` queues must wait for someone to start them (#1620).
 
     None is auth-off or a legacy ownerless API key, neither of which has a
@@ -1994,7 +2253,7 @@ def queue_review_required_for(user: User | None) -> bool:
     )
 
 
-def may_start_queue_item(user: User | None, can_modify_all: bool, created_by_id: int | None) -> bool:
+def may_start_queue_item(user: User | ApiKeyActor | None, can_modify_all: bool, created_by_id: int | None) -> bool:
     """Whether the caller may start a waiting queue item (#1620).
 
     ``can_modify_all`` is what ``require_ownership_permission`` answered for
@@ -2076,6 +2335,27 @@ async def get_request_actor(
 RequestActor = Depends(get_request_actor)
 
 
+async def actor_still_exists(db: AsyncSession, actor: User | ApiKeyActor | None) -> bool:
+    """Whether the user a request started as still exists.
+
+    A request that downloads or extracts for seconds to minutes writes the id
+    it started with. A user deleted meanwhile leaves it pointing at nobody, or
+    on SQLite, which gives a freed id to the next user created, at someone
+    else; the name tells them apart. No actor (auth off) or a key without an
+    owner has no user to lose.
+    """
+    if actor is None or actor.id is None:
+        return True
+    found = await db.scalar(select(User.id).where(User.id == actor.id, User.username == actor.username))
+    return found is not None
+
+
+async def require_actor_still_exists(db: AsyncSession, actor: User | ApiKeyActor | None) -> None:
+    """Stop a request whose user was deleted while it ran, before it saves anything as theirs."""
+    if not await actor_still_exists(db, actor):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="This account was deleted")
+
+
 async def get_media_or_request_printer_scope(
     token: str | None = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
@@ -2084,10 +2364,11 @@ async def get_media_or_request_printer_scope(
     """``RequestPrinterScope`` for routes an ``<img>`` may load with ``?token=``.
 
     Such a request carries a media token and no headers, so the header-based
-    lookup would find nobody and answer with no printers. With headers present
-    they win, exactly as in ``require_media_token_*``.
+    lookup would find nobody and answer with no printers. A media token wins
+    over any headers sent with it, exactly as in ``require_media_token_*``:
+    the printers are always those of the caller the permission check named.
     """
-    if token and credentials is None and x_api_key is None:
+    if token:
         async with async_session() as db:
             if not await is_auth_enabled(db):
                 return ALL_PRINTERS
@@ -2127,9 +2408,6 @@ def require_printer_permission_if_auth_enabled(*permissions: str | Permission):
 
 
 # Convenience dependencies - these are functions that return Depends objects
-def RequireAdmin():
-    """Dependency that requires admin role."""
-    return Depends(require_role("admin"))
 
 
 def RequireAdminIfAuthEnabled():
@@ -2137,84 +2415,6 @@ def RequireAdminIfAuthEnabled():
     return Depends(require_admin_if_auth_enabled())
 
 
-def require_permission(*permissions: str | Permission):
-    """Dependency factory that requires user to have ALL specified permissions.
-
-    Accepts both JWT tokens (via Authorization: Bearer header) and API keys
-    (via X-API-Key header or Authorization: Bearer bb_xxx).
-
-    Args:
-        *permissions: Permission strings or Permission enum values to require
-
-    Returns:
-        A dependency function that validates permissions
-    """
-    # Convert Permission enums to strings
-    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
-
-    async def permission_checker(
-        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
-        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    ) -> User | None:
-        async with async_session() as db:
-            # Check for API key first (X-API-Key header)
-            if x_api_key:
-                api_key = await _validate_api_key(db, x_api_key)
-                if api_key:
-                    await authorize_api_key(db, api_key, perm_strings)
-                    return None  # API key valid, allow access
-
-            credentials_exception = HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail="Could not validate credentials",
-                headers={"WWW-Authenticate": "Bearer"},
-            )
-
-            if credentials is None:
-                raise credentials_exception
-
-            token = credentials.credentials
-            # Check if it's an API key (starts with bb_)
-            if token.startswith("bb_"):
-                api_key = await _validate_api_key(db, token)
-                if api_key:
-                    await authorize_api_key(db, api_key, perm_strings)
-                    return None  # API key valid, allow access
-                raise HTTPException(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    detail="Invalid API key",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
-
-            # Otherwise treat as JWT
-            try:
-                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
-                username: str = payload.get("sub")
-                if username is None:
-                    raise credentials_exception
-                jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti, db):
-                    raise credentials_exception
-                iat: int | float | None = payload.get("iat")
-            except JWTError:
-                raise credentials_exception
-
-            user = await get_user_by_username(db, username)
-            if user is None or not user.is_active:
-                raise credentials_exception
-            if not _is_token_fresh(iat, user):
-                raise credentials_exception
-
-            if not user.has_all_permissions(*perm_strings):
-                raise HTTPException(
-                    status_code=status.HTTP_403_FORBIDDEN,
-                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
-                )
-            return user
-
-    return permission_checker
-
-
 def require_permission_if_auth_enabled(*permissions: str | Permission):
     """Dependency factory that checks permissions only if auth is enabled.
 
@@ -2326,11 +2526,6 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
     return permission_checker
 
 
-def RequirePermission(*permissions: str | Permission):
-    """Convenience dependency that requires ALL specified permissions."""
-    return Depends(require_permission(*permissions))
-
-
 def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     """Convenience dependency that requires permissions if auth is enabled."""
     return Depends(require_permission_if_auth_enabled(*permissions))
@@ -2476,18 +2671,20 @@ def require_camera_stream_token_if_auth_enabled():
     tell one user's rows from another's (#3025).
     """
 
-    async def checker(printer_id: int, token: str | None = None) -> None:
+    async def checker(printer_id: int, token: str | None = None) -> StreamOpener:
         async with async_session() as db:
             if not await is_auth_enabled(db):
-                return  # Auth disabled, allow access
-        scope = await verify_camera_stream_token(token) if token else None
-        if scope is None:
+                return StreamOpener(auth_on=False)  # Auth disabled, allow access
+        access = await camera_stream_access(token) if token else None
+        if access is None:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
             )
+        scope, opener = access
         # The token's minter may not see this printer (#1727)
         scope.ensure(printer_id)
+        return opener
 
     return checker
 
@@ -2582,14 +2779,15 @@ def require_ownership_permission(
     - User with ``all_permission`` can modify any item
     - User with ``own_permission`` can only modify items where created_by_id == user.id
     - Ownerless items (created_by_id = null) require ``all_permission``
-    - API keys (via X-API-Key header or Bearer bb_xxx) must satisfy the
-      ``all_permission``'s API-key scope flag (e.g. ``can_queue`` for
-      ``QUEUE_UPDATE_ALL``) and then receive ``can_modify_all=True``.
-      OWN/ALL ownership pairs map to the same scope flag in
-      ``_APIKEY_SCOPE_BY_PERMISSION`` so checking ``all_permission`` is the
-      correct gate; API keys have no per-row ownership identity. Pre-
-      GHSA-r2qv-8222-hqg3 fix this returned ``(None, True)`` for any valid
-      key with no scope check — see ``core/auth.py`` allowlist commentary.
+    - API keys (via X-API-Key header or Bearer bb_xxx) act as their owner:
+      a key whose owner holds ``all_permission`` gets ``(None, True)``; a key
+      whose owner holds only ``own_permission`` gets ``(ApiKeyActor, False)``,
+      so the route's per-row check compares rows against the owner's id.
+      Either way the key needs the permission's scope flag (OWN and ALL map
+      to the same one). A legacy key without an owner owns nothing and still
+      needs ``all_permission``. Pre-GHSA-r2qv-8222-hqg3 this returned
+      ``(None, True)`` for any valid key with no scope check — see
+      ``core/auth.py`` allowlist commentary.
 
     Returns:
         A dependency function that returns (user, can_modify_all).
@@ -2599,10 +2797,22 @@ def require_ownership_permission(
     all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission
     own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission
 
+    async def _api_key_ownership(db: AsyncSession, api_key: APIKey) -> tuple[ApiKeyActor | None, bool]:
+        owner = await resolve_apikey_owner(db, api_key)
+        if owner is None:
+            # A legacy key without an owner has no id to own rows by.
+            _check_apikey_permissions(api_key, [all_perm])
+            return None, True
+        _check_apikey_permissions(api_key, [all_perm, own_perm], owner=owner, require_any=True)
+        actor = ApiKeyActor(api_key, owner)
+        if actor.has_permission(all_perm):
+            return None, True
+        return actor, False
+
     async def checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    ) -> tuple[User | None, bool]:
+    ) -> tuple[User | ApiKeyActor | None, bool]:
         """Returns (user, can_modify_all).
 
         - can_modify_all=True: user can modify any item
@@ -2616,17 +2826,11 @@ def require_ownership_permission(
             # GHSA-r2qv-8222-hqg3: previously API keys received (None, True)
             # unconditionally on ownership-modify routes — a "queue-only" key
             # could delete any user's archives, library files, queue items.
-            # OWN and ALL ownership perms both map to the same scope flag
-            # (e.g. both QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL → can_queue),
-            # so checking ``all_perm`` against the api_key's scope is the
-            # correct gate. API keys don't have per-row ownership identity, so
-            # on pass we keep can_modify_all=True (preserves prior intent,
-            # narrows access to keys with the right scope flag).
+            # A key now passes only with the scope flag, and acts as its owner.
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    await authorize_api_key(db, api_key, [all_perm])
-                    return None, True
+                    return await _api_key_ownership(db, api_key)
 
             # Check for Bearer token (could be JWT or API key)
             if credentials is not None:
@@ -2635,8 +2839,7 @@ def require_ownership_permission(
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        await authorize_api_key(db, api_key, [all_perm])
-                        return None, True
+                        return await _api_key_ownership(db, api_key)
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Invalid API key",
@@ -2778,9 +2981,9 @@ def require_media_token_ownership(
     token holder.
 
     Header callers are delegated to :func:`require_ownership_permission`
-    unchanged -- including its API-key rule, where a key satisfying the ALL
-    permission's scope flag gets ``can_read_all=True`` because keys have no
-    per-row identity.
+    unchanged -- including its API-key rule, where a key acts as its owner: its
+    owner's ALL permission gives ``can_read_all=True``, the OWN one an
+    ``ApiKeyActor`` limited to the owner's rows.
     """
     all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission
     own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission
@@ -2790,7 +2993,7 @@ def require_media_token_ownership(
         token: str | None = None,
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    ) -> tuple[User | None, bool]:
+    ) -> tuple[User | ApiKeyActor | None, bool]:
         async with async_session() as db:
             if not await is_auth_enabled(db):
                 return None, True  # Auth disabled, allow all

+ 6 - 8
backend/app/core/compat.py

@@ -1,11 +1,9 @@
-"""Compatibility shims for older Python versions."""
+"""Kept for the modules that import StrEnum from here.
 
-import sys
+Python 3.11 is the minimum, so this is enum.StrEnum itself; new code can
+import it from enum directly.
+"""
 
-if sys.version_info >= (3, 11):
-    from enum import StrEnum
-else:
-    from enum import Enum
+from enum import StrEnum
 
-    class StrEnum(str, Enum):
-        """Drop-in replacement for enum.StrEnum on Python < 3.11."""
+__all__ = ["StrEnum"]

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1031 - 43
backend/app/core/database.py


+ 1 - 2
backend/app/core/local_config.py

@@ -26,11 +26,10 @@ exists). Here we just expose state so the frontend can render accordingly.
 from __future__ import annotations
 
 import logging
+import tomllib
 from pathlib import Path
 from typing import Literal, TypedDict
 
-import tomllib
-
 log = logging.getLogger(__name__)
 
 DEFAULT_PATH = Path("/etc/bambuddy/local.toml")

+ 169 - 6
backend/app/core/logging_filters.py

@@ -19,6 +19,7 @@ from __future__ import annotations
 import asyncio
 import logging
 import re
+from urllib.parse import urlsplit
 
 # ``scheme://user:secret@host`` — the only URL shape that carries a secret.
 # Both userinfo parts exclude ``/`` so the match can never run past the
@@ -40,13 +41,27 @@ import re
 # makes the work per offset constant. 63 is far above any real scheme (the
 # longest registered one is under 20 characters), and a longer pseudo-scheme
 # still gets its secret masked — the match simply starts from a later offset.
-URL_CREDENTIALS_PATTERN = re.compile(
-    r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@"
-)
+URL_CREDENTIALS_PATTERN = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<login>[^/\s]+)@")
+
+
+def _mask_login(match: re.Match[str]) -> str:
+    # The login runs to the last @ before the path. It may hold an unencoded ?
+    # or #, which cannot be told from a query: masked rather than risk it. Its
+    # user part (up to the first ':') survives; without a ':' it may be a
+    # token, so it all goes.
+    login = match.group("login")
+    shown = f"{login.split(':', 1)[0]}:[REDACTED]" if ":" in login else "[REDACTED]"
+    return f"{match.group('scheme')}{shown}@"
 
 
 def redact_url_credentials(text: str | None) -> str | None:
-    """Mask the password in every ``scheme://user:secret@host`` URL in *text*.
+    """Mask the login secret in every ``scheme://login@host`` URL in *text*.
+
+    The login runs to the last ``@`` before the path (``?`` and ``#``
+    included, when in doubt it is masked), so an empty user
+    (``:secret@``), an unencoded ``@`` in the user and a bare token
+    (``token@``, masked whole) are covered as well as ``user:secret@``.
+    Also used for camera URLs shown to those who may not edit the printer.
 
     Subprocesses echo their input URL back at us — ffmpeg prints the RTSP
     input in its ``Input #0`` line, so logging its stderr verbatim publishes
@@ -59,7 +74,7 @@ def redact_url_credentials(text: str | None) -> str | None:
     """
     if not text or "://" not in text or "@" not in text:
         return text
-    return URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>\g<user>:[REDACTED]@", text)
+    return URL_CREDENTIALS_PATTERN.sub(_mask_login, text)
 
 
 # ``?token=<value>`` and its kin. The SPA passes its short-lived WebSocket and
@@ -70,7 +85,7 @@ def redact_url_credentials(text: str | None) -> str | None:
 # and from there anything that collects container logs -- the appliance's
 # support bundle among them. The value ends at the next ``&``, whitespace or
 # quote, which is where uvicorn's format closes the path.
-QUERY_TOKEN_PATTERN = re.compile(r"(?P<key>[?&](?:token|access_token|api_key)=)[^&\s\"']+")
+QUERY_TOKEN_PATTERN = re.compile(r"(?P<key>[?&](?:token|access_token|api_key|url)=)[^&\s\"']+")
 
 
 def redact_query_tokens(text: str | None) -> str | None:
@@ -80,6 +95,118 @@ def redact_query_tokens(text: str | None) -> str | None:
     return QUERY_TOKEN_PATTERN.sub(r"\g<key>[REDACTED]", text)
 
 
+# Loggers whose lines name a device by its whole URL, where the path or query
+# can be the device's credential in a shape no pattern recognises (a webhook id,
+# an ``auth_key``). Read back, their URLs are reduced to scheme and host.
+HOST_ONLY_LOGGERS = ("backend.app.services.rest_smart_plug", "httpx")
+
+# Runs to whitespace only: a quote or angle bracket may sit inside a stored
+# URL, and ending there would leave the rest of it in the line.
+_URL_IN_TEXT = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://\S+")
+
+
+def url_host_only(url: str | None) -> str:
+    """A URL reduced to scheme and host (with port) for a log line.
+
+    The login, path, query and fragment go. An ``@`` after the part the parser
+    took for the host means a login it could not find (an unencoded ``?``,
+    ``/`` or ``#`` in the password): that part may then hold the login, so it
+    goes as well.
+    """
+    try:
+        parts = urlsplit(url or "")
+    except ValueError:
+        return "<invalid URL>"
+    if not parts.scheme or not parts.netloc:
+        return "<invalid URL>"
+    if "@" in (url or "")[len(parts.scheme) + 3 + len(parts.netloc) :]:
+        return f"{parts.scheme}://<hidden>"
+    return f"{parts.scheme}://{parts.netloc.rsplit('@', 1)[-1]}"
+
+
+# Masked to the end of the line, in lines from those loggers: the REST plug
+# service's messages carry raw input (a refused URL, unparsed headers) or an
+# error's own text, which may quote a URL or a header's value. Covers every
+# message it has logged.
+_MASKED_TAILS = re.compile(r"(invalid URL: |headers JSON: |returned error: |REST smart plug at \S+?: ).*")
+
+
+# An HTTP client refusing a header quotes the header's value, commonly a token,
+# whichever service sent it: masked to the end of the line.
+_REFUSED_HEADER = re.compile(r"(Illegal header (?:value|name) ).*")
+_UNMASKED_REFUSED_HEADER = re.compile(r"Illegal header (?:value|name) (?!\[REDACTED\])")
+
+
+def redact_refused_headers(text: str | None) -> str | None:
+    """Mask the header an HTTP client quotes when it refuses one."""
+    if not text or "Illegal header" not in text:
+        return text
+    return _REFUSED_HEADER.sub(r"\1[REDACTED]", text)
+
+
+def _redact_refused_header_arg(value: object) -> object:
+    """*value* as masked text when its text quotes a refused header (an
+    exception, a string), else *value* itself."""
+    text = value if isinstance(value, str) else str(value)
+    return redact_refused_headers(text) if "Illegal header" in text else value
+
+
+class RefusedHeaderRedactFilter(logging.Filter):
+    """Mask a refused header's value in a record before a handler writes it.
+
+    The value reaches a record as an argument (``"...: %s", e``), in the
+    message itself, or in the traceback of ``logger.exception``, so the
+    rendered message and traceback are checked, not the format string. A
+    record with nothing to mask is left untouched.
+
+    Attach to the handlers that write lines (a handler filter sees records
+    from every logger that propagates to it) and to uvicorn's loggers, which
+    do not propagate to root.
+    """
+
+    _formatter = logging.Formatter()
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        try:
+            message = record.getMessage()
+        except Exception:  # noqa: BLE001 — a bad format string is the handler's error to report, not the caller's
+            message = ""
+        if "Illegal header" in message:
+            # Masked in the arguments, keeping their shape: uvicorn's access
+            # formatter unpacks them.
+            if isinstance(record.args, tuple):
+                record.args = tuple(_redact_refused_header_arg(a) for a in record.args)
+            elif isinstance(record.args, dict):
+                record.args = {k: _redact_refused_header_arg(v) for k, v in record.args.items()}
+            try:
+                masked = record.getMessage()
+            except Exception:  # noqa: BLE001 — the masked format string no longer fits its arguments
+                masked = message
+            if _UNMASKED_REFUSED_HEADER.search(masked):
+                # The quote is (also) in the format string: the rendered
+                # line, masked, replaces both.
+                record.msg = redact_refused_headers(message)
+                record.args = None
+        if record.exc_info and not record.exc_text:
+            # Formatters reuse exc_text once set, so the masked text is what
+            # every handler then writes.
+            record.exc_text = self._formatter.formatException(record.exc_info)
+        if record.exc_text and "Illegal header" in record.exc_text:
+            record.exc_text = redact_refused_headers(record.exc_text)
+        return True
+
+
+def redact_device_urls(text: str | None) -> str | None:
+    """Every URL in *text* reduced to :func:`url_host_only`, and the tails in
+    ``_MASKED_TAILS`` masked."""
+    if not text:
+        return text
+    text = _MASKED_TAILS.sub(r"\1[REDACTED]", text)
+    if "://" not in text:
+        return text
+    return _URL_IN_TEXT.sub(lambda match: url_host_only(match.group(0)), text)
+
+
 class QueryTokenRedactFilter(logging.Filter):
     """Mask query-string tokens in uvicorn's request and WebSocket log lines.
 
@@ -140,6 +267,42 @@ class WriteRequestsOnlyFilter(logging.Filter):
         return any(token in message for token in self._WRITE_VERB_TOKENS)
 
 
+class ShutdownCancelledRequestFilter(logging.Filter):
+    """One INFO line for a request uvicorn cancels at the end of a shutdown.
+
+    The service caps the graceful shutdown (``--timeout-graceful-shutdown``)
+    because an MJPEG camera stream never ends by itself. When the cap is
+    reached uvicorn logs "Cancel N running task(s)" and then, for each
+    cancelled request, "Exception in ASGI application" with the full
+    ``CancelledError`` traceback, both at ERROR. That happens on every restart
+    with a camera view open and is not an error. Only that cancellation is
+    rewritten, recognised by uvicorn's own cancel message; any other failure,
+    and any other cancellation, keeps its level and traceback.
+
+    Attach to ``logging.getLogger("uvicorn.error")`` before any filter that
+    renders the traceback.
+    """
+
+    _CANCEL_MESSAGE = "timeout graceful shutdown exceeded"
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        exc = record.exc_info[1] if record.exc_info else None
+        if isinstance(exc, asyncio.CancelledError) and self._CANCEL_MESSAGE in str(exc):
+            record.msg = "Request closed at shutdown, still open after the grace period (a camera stream, for example)"
+            record.args = None
+            record.exc_info = None
+            record.exc_text = None
+            self._as_info(record)
+        elif exc is None and isinstance(record.msg, str) and record.msg.endswith(self._CANCEL_MESSAGE):
+            self._as_info(record)
+        return True
+
+    @staticmethod
+    def _as_info(record: logging.LogRecord) -> None:
+        record.levelno = logging.INFO
+        record.levelname = logging.getLevelName(logging.INFO)
+
+
 class CancelledPoolNoiseFilter(logging.Filter):
     """Drop SQLAlchemy connection-pool log records driven by request cancellation.
 

+ 94 - 15
backend/app/core/printer_scope.py

@@ -60,10 +60,6 @@ class PrinterScope:
             return self
         return PrinterScope(self.printer_ids & other.printer_ids)
 
-    def filter_ids(self, printer_ids: Iterable[int]) -> list[int]:
-        """``printer_ids`` minus the ones out of scope, order kept."""
-        return [pid for pid in printer_ids if self.allows(pid)]
-
     def where(self, column):
         """A WHERE clause limiting ``column`` (a printer id column) to the scope.
 
@@ -86,14 +82,18 @@ ALL_PRINTERS = PrinterScope()
 
 
 def ensure_model_target_allowed(user, scope: PrinterScope) -> None:
-    """Refuse an "any printer of a model" job from a limited caller with no user.
+    """Refuse an "any printer of a model" job from a limited caller that is no user.
 
     The scheduler keeps such a job within its *creator's* scope, but a job
-    queued through an API key records no creator, so a key limited to certain
-    printers could otherwise reach the rest of the fleet through it. Users are
-    unaffected: their jobs carry their id.
+    queued through an API key records no creator, or its owner as creator, and
+    a key may be limited to fewer printers than its owner, so a limited key
+    could otherwise reach the rest of the fleet through it. A key comes as
+    ``None`` or as its ``ApiKeyActor``. Users are unaffected: their jobs carry
+    their id.
     """
-    if user is None and not scope.is_unrestricted:
+    from backend.app.core.auth import ApiKeyActor
+
+    if (user is None or isinstance(user, ApiKeyActor)) and not scope.is_unrestricted:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             detail="A caller limited to certain printers must queue to a specific printer, not to any printer of a model",
@@ -191,9 +191,88 @@ def api_key_own_scope(api_key) -> PrinterScope:
     return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
 
 
-async def group_restricts_printers(db: AsyncSession, group_ids: Iterable[int]) -> bool:
-    ids = list(group_ids)
-    if not ids:
-        return False
-    result = await db.execute(select(Group.id).where(Group.id.in_(ids), Group.restrict_printers.is_(True)).limit(1))
-    return result.first() is not None
+async def drop_printers_from_api_keys(db, deleted: Iterable[int] | None = None) -> None:
+    """Take deleted printers out of the API keys limited to them.
+
+    *deleted*: the printers being deleted, or None for every id that no longer
+    has a printer (the start-up repair). SQLite gives a freed id to the next
+    printer created, so a stale id would hand the key that printer. A key whose
+    last printer goes keeps an empty list, which reaches no printer; None would
+    reach all of them. *db* is a session or a connection.
+    """
+    from sqlalchemy import update
+
+    from backend.app.models.api_key import APIKey
+    from backend.app.models.printer import Printer
+
+    if deleted is None:
+        existing = set((await db.execute(select(Printer.id))).scalars().all())
+
+        def keeps(printer_id: int) -> bool:
+            return printer_id in existing
+    else:
+        gone = set(deleted)
+
+        def keeps(printer_id: int) -> bool:
+            return printer_id not in gone
+
+    for key_id, printer_ids in (await db.execute(select(APIKey.id, APIKey.printer_ids))).all():
+        if not isinstance(printer_ids, list):
+            continue  # all printers
+        kept = [printer_id for printer_id in printer_ids if keeps(printer_id)]
+        if kept != printer_ids:
+            await db.execute(update(APIKey).where(APIKey.id == key_id).values(printer_ids=kept))
+
+
+async def drop_printers_from_obico(db, deleted: Iterable[int] | None = None) -> None:
+    """Take deleted printers out of the printers Obico detection watches.
+
+    As drop_printers_from_api_keys: SQLite would enrol the next printer given a
+    stale id, sending its camera to the ML server and pausing its prints. An
+    empty or missing list means all printers and is left as it is; a list whose
+    last printer goes becomes "[]", which watches none.
+    """
+    import json
+
+    from sqlalchemy import update
+
+    from backend.app.models.printer import Printer
+    from backend.app.models.settings import Settings
+
+    key = "obico_enabled_printers"
+    raw = (await db.execute(select(Settings.value).where(Settings.key == key))).scalar()
+    if not raw:
+        return
+    try:
+        printer_ids = json.loads(raw)
+    except ValueError:
+        return
+    if not isinstance(printer_ids, list):
+        return
+    if deleted is None:
+        existing = set((await db.execute(select(Printer.id))).scalars().all())
+        kept = [printer_id for printer_id in printer_ids if printer_id in existing]
+    else:
+        gone = set(deleted)
+        kept = [printer_id for printer_id in printer_ids if printer_id not in gone]
+    if kept != printer_ids:
+        # Written as the settings page writes it ("[1,3]"); "[1, 3]" reads there
+        # as an unsaved change and saves itself again.
+        value = json.dumps(kept, separators=(",", ":"))
+        await db.execute(update(Settings).where(Settings.key == key).values(value=value))
+
+
+async def ensure_printers_exist(db: AsyncSession, printer_ids: Iterable[int | None] | None) -> None:
+    """400 for an id with no printer, before it is stored as a limit.
+
+    SQLite enforces no foreign keys and gives a freed id to the next printer
+    created, which a stored stale id would hand whatever it limits.
+    """
+    from backend.app.models.printer import Printer
+
+    wanted = {printer_id for printer_id in printer_ids or () if printer_id is not None}
+    if not wanted:
+        return
+    found = set((await db.execute(select(Printer.id).where(Printer.id.in_(wanted)))).scalars().all())
+    if missing := sorted(wanted - found):
+        raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown printer: {', '.join(map(str, missing))}")

+ 84 - 0
backend/app/core/response_redaction.py

@@ -0,0 +1,84 @@
+"""A refused header's value masked in every JSON response.
+
+An HTTP client refusing a header quotes the header's value, and the value is
+usually a credential: a stored token saved with a stray space or line break.
+That error text reaches the API from many places -- a provider's stored error,
+a backup or restore result, a connection test, a status endpoint, rows stored
+before any of them masked it -- so it is masked once, where every JSON body
+leaves, rather than at each place that may hand one on. Logs are masked by
+``logging_filters.RefusedHeaderRedactFilter``.
+"""
+
+from __future__ import annotations
+
+import re
+
+from starlette.datastructures import MutableHeaders
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+_MARKER = b"Illegal header "
+# Inside a JSON string: masked up to the string's closing quote, so the body
+# stays valid JSON.
+_REFUSED_HEADER_IN_JSON = re.compile(rb'(Illegal header (?:value|name) )(?:[^"\\]|\\.)*')
+
+
+def redact_refused_headers_json(body: bytes) -> bytes:
+    """*body*, a JSON document, with every quoted refused header masked."""
+    if _MARKER not in body:
+        return body
+    return _REFUSED_HEADER_IN_JSON.sub(rb"\1[REDACTED]", body)
+
+
+class RefusedHeaderResponseMask:
+    """ASGI middleware masking refused headers in JSON response bodies.
+
+    Install it innermost (added before any other middleware): outer
+    ``@app.middleware`` layers re-stream a body in chunks. A body with a
+    Content-Length is collected and masked whole, with the length fixed up; a
+    streamed one is masked chunk by chunk.
+    """
+
+    def __init__(self, app: ASGIApp) -> None:
+        self.app = app
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+        if scope["type"] != "http":
+            await self.app(scope, receive, send)
+            return
+
+        start: Message | None = None
+        sized = False
+        chunks: list[bytes] = []
+
+        async def masking_send(message: Message) -> None:
+            nonlocal start, sized
+            if message["type"] == "http.response.start":
+                headers = MutableHeaders(raw=message["headers"])
+                media_type = headers.get("content-type", "").split(";", 1)[0].strip().lower()
+                if not (media_type == "application/json" or media_type.endswith("+json")):
+                    await send(message)
+                    return
+                start = message
+                sized = "content-length" in headers
+                if not sized:
+                    await send(start)
+                return
+            if start is None or message["type"] != "http.response.body":
+                await send(message)
+                return
+            if not sized:
+                await send({**message, "body": redact_refused_headers_json(message.get("body", b""))})
+                return
+            chunks.append(message.get("body", b""))
+            if message.get("more_body", False):
+                return
+            original = b"".join(chunks)
+            body = redact_refused_headers_json(original)
+            if body is not original:
+                # Only a masked body changes length; a HEAD response's empty
+                # body keeps the length of the GET body it stands for.
+                MutableHeaders(raw=start["headers"])["content-length"] = str(len(body))
+            await send(start)
+            await send({"type": "http.response.body", "body": body, "more_body": False})
+
+        await self.app(scope, receive, masking_send)

+ 314 - 0
backend/app/core/sqlite_fk.py

@@ -0,0 +1,314 @@
+"""Selected ON DELETE rules of the foreign keys, applied by hand on SQLite.
+
+The SQLite database runs with ``PRAGMA foreign_keys`` off: turning it on breaks
+the migrations' table rebuilds (measured: every test errors with "no such table:
+main.print_archives"). So a delete there applies none of the models' ON DELETE
+CASCADE / SET NULL rules. Rows pointing at the deleted row keep its id, and
+SQLite hands the highest freed id to the next row created, which then inherits
+them: a queue item prints another archive, a project shows another project's
+files.
+
+Only the references in APPLIED are handled: those where a dangling id does harm
+and the model's rule is what the app wants. Not every model rule is (deleting a
+printer would make a printer-scoped notification provider global, and delete
+finished queue history), so a reference is added here deliberately, never by
+default.
+
+Hooks apply them at every ORM delete, so no delete path needs to remember:
+
+- ``after_delete`` on every mapped class collects the ids of ``db.delete(obj)``,
+  and ``after_flush`` applies the rules once per flush, one statement per rule;
+- ``do_orm_execute`` applies them before ``db.execute(delete(Model)...)``.
+
+Rule statements go straight to the connection and don't trigger the hooks again.
+``sweep_orphans`` repairs what earlier deletes left behind; init_db runs it at
+every start. Nothing here does anything on PostgreSQL, which enforces the rules
+itself.
+"""
+
+import logging
+from functools import cache
+from typing import Any
+
+from sqlalchemy import Column, Connection, Delete, ForeignKey, Table, delete, event, exists, select, text, update
+from sqlalchemy.orm import Mapper, ORMExecuteState, Session
+
+from backend.app.core.database import Base
+
+logger = logging.getLogger(__name__)
+
+# (child table, child column) whose ON DELETE rule is applied on SQLite.
+APPLIED: frozenset[tuple[str, str]] = frozenset(
+    {
+        # print_archives
+        ("active_print_spoolman", "archive_id"),
+        ("budget_reservations", "print_archive_id"),
+        ("library_folders", "archive_id"),
+        ("pending_uploads", "archived_id"),
+        ("pipeline_runs", "source_archive_id"),
+        ("print_batches", "archive_id"),
+        ("print_log_entries", "archive_id"),
+        ("print_queue", "archive_id"),
+        ("project_bom_items", "archive_id"),
+        ("spool_usage_history", "archive_id"),
+        # spool
+        ("spool_usage_history", "spool_id"),
+        ("telegram_pending_verdicts", "archive_id"),
+        ("wallet_transactions", "print_archive_id"),
+        # library_files. print_queue.library_file_id is CASCADE in the model, but
+        # library deletes run release_queue_references first, which keeps the rows
+        # (finished plates count towards a batch); the rule then finds nothing.
+        ("library_file_tags", "file_id"),
+        ("pipeline_runs", "sliced_library_file_id"),
+        ("pipeline_runs", "source_library_file_id"),
+        ("print_archives", "library_file_id"),
+        ("print_batches", "library_file_id"),
+        ("print_queue", "library_file_id"),
+        ("print_queue_variants", "library_file_id"),
+        # print_queue
+        ("pipeline_jobs", "queue_entry_id"),
+        ("print_log_entries", "queue_item_id"),
+        ("print_queue_variants", "queue_item_id"),
+        ("wallet_transactions", "print_queue_id"),
+        # print_batches
+        ("print_batch_plates", "batch_id"),
+        ("print_queue", "batch_id"),
+        # projects
+        ("library_files", "project_id"),
+        ("library_folders", "project_id"),
+        ("pending_uploads", "project_id"),
+        ("print_archives", "project_id"),
+        ("print_batches", "project_id"),
+        ("print_queue", "project_id"),
+        ("project_bom_items", "project_id"),
+        # printers: what the next printer given the id would otherwise take
+        # over. Only these: providers, plugs and virtual printers go with the
+        # printer in its delete route, and the rest is decided separately. The
+        # print being tracked would be restored for a new printer that is already
+        # printing, charging its filament to the deleted printer's spools.
+        ("active_print_sessions", "printer_id"),
+        ("active_print_spoolman", "printer_id"),
+        ("pipeline_jobs", "assigned_printer_id"),
+        ("slicer_pipelines", "target_printer_id"),
+        # A deleted printer's spools would stay "assigned" to it (SA-1).
+        ("spool_assignment", "printer_id"),
+        # smart_plugs: a printer delete now deletes its plugs; their energy
+        # history would pass to the next plug given the id.
+        ("smart_plug_energy_snapshots", "plug_id"),
+        # users: everything a user owns goes with them or loses its owner, or a
+        # new user given the freed id inherits it (wallet, cost centres, private
+        # folders). Without an owner, folders and private cost centres are left
+        # to admins, not opened to everyone.
+        ("announcement_reads", "user_id"),
+        ("api_keys", "user_id"),
+        ("connected_app_grants", "user_id"),
+        ("connected_apps", "created_by_id"),
+        ("cost_center_members", "user_id"),
+        ("cost_centers", "owner_user_id"),
+        ("file_variant_groups", "created_by_id"),
+        ("library_files", "created_by_id"),
+        ("library_folders", "created_by_id"),
+        ("long_lived_tokens", "user_id"),
+        ("pipeline_runs", "created_by"),
+        ("print_archives", "created_by_id"),
+        ("print_batches", "created_by_id"),
+        ("print_log_entries", "created_by_id"),
+        ("print_queue", "created_by_id"),
+        ("scheduled_dryings", "created_by_id"),
+        ("slicer_pipelines", "created_by"),
+        ("sponsor_toast_state", "user_id"),
+        ("user_email_preferences", "user_id"),
+        ("user_groups", "user_id"),
+        ("user_oidc_links", "user_id"),
+        ("user_otp_codes", "user_id"),
+        ("user_totp", "user_id"),
+        ("user_wallets", "user_id"),
+        ("wallet_transactions", "created_by_user_id"),
+        ("wallet_transactions", "user_id"),
+    }
+)
+
+_PENDING = "bambuddy_sqlite_fk_pending"
+_TABLES = "bambuddy_sqlite_fk_tables"
+# Keeps any one statement's IN list small.
+_CHUNK = 500
+_MAX_DEPTH = 32
+
+
+def _applies(connection: Connection) -> bool:
+    """Only SQLite: PostgreSQL enforces the rules itself."""
+    return connection.dialect.name == "sqlite"
+
+
+def _existing_tables(connection: Connection) -> set[str]:
+    """Tables the database has (cached on the connection); a model without one has no rows to fix."""
+    tables = connection.info.get(_TABLES)
+    if tables is None:
+        result = connection.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'"))
+        tables = connection.info[_TABLES] = {row[0] for row in result}
+    return tables
+
+
+def _single_primary_key(table: Table) -> Column | None:
+    columns = list(table.primary_key.columns)
+    return columns[0] if len(columns) == 1 else None
+
+
+@cache
+def _applied_foreign_keys() -> tuple[ForeignKey, ...]:
+    """The model foreign keys of APPLIED references to a single-column primary key."""
+    from backend.app.core.database import load_all_models
+
+    found = []
+    for table in load_all_models().sorted_tables:
+        for fk in table.foreign_keys:
+            if (
+                (table.name, fk.parent.name) in APPLIED
+                and fk.ondelete in ("CASCADE", "SET NULL")
+                and isinstance(fk.column.table, Table)
+                and fk.column is _single_primary_key(fk.column.table)
+            ):
+                found.append(fk)
+    return tuple(found)
+
+
+@cache
+def _rules(parent_table: str) -> tuple[tuple[Table, Column, str], ...]:
+    """(child table, child column, ON DELETE action) of the APPLIED references to *parent_table*."""
+    return tuple(
+        (fk.parent.table, fk.parent, str(fk.ondelete))
+        for fk in _applied_foreign_keys()
+        if fk.column.table.name == parent_table and isinstance(fk.parent.table, Table)
+    )
+
+
+def _apply(connection: Connection, parent_table: str, ids: Any, depth: int = 0) -> None:
+    """Apply the rules for rows of *parent_table* whose primary key is in *ids* (a list or a select)."""
+    rules = _rules(parent_table)
+    if not rules or depth > _MAX_DEPTH:
+        return
+    if isinstance(ids, list):
+        for start in range(0, len(ids), _CHUNK):
+            _apply_chunk(connection, rules, ids[start : start + _CHUNK], depth)
+    else:
+        _apply_chunk(connection, rules, ids, depth)
+
+
+def _apply_chunk(connection: Connection, rules: tuple, ids: Any, depth: int) -> None:
+    tables = _existing_tables(connection)
+    for child, column, action in rules:
+        if child.name not in tables:
+            continue
+        if action == "SET NULL":
+            connection.execute(update(child).where(column.in_(ids)).values({column.name: None}))
+            continue
+        child_key = _single_primary_key(child)
+        if child_key is not None:
+            # By depth, not by table: a table that refers to itself recurses too.
+            _apply(connection, child.name, select(child_key).where(column.in_(ids)), depth + 1)
+        connection.execute(delete(child).where(column.in_(ids)))
+
+
+@event.listens_for(Base, "after_delete", propagate=True)
+def _collect_orm_delete(mapper: Mapper, connection: Connection, target: Any) -> None:
+    table = mapper.local_table
+    session = Session.object_session(target)
+    if session is None or not isinstance(table, Table) or not _rules(table.name) or not _applies(connection):
+        return
+    pending = session.info.setdefault(_PENDING, {})
+    pending.setdefault(table.name, []).append(mapper.primary_key_from_instance(target)[0])
+
+
+@event.listens_for(Session, "after_flush")
+def _apply_orm_deletes(session: Session, flush_context: Any) -> None:
+    pending = session.info.pop(_PENDING, None)
+    if not pending:
+        return
+    connection = session.connection()
+    # The parent rows are already gone; with foreign keys off that doesn't stop
+    # the rules from finding their dependents.
+    for table, ids in pending.items():
+        _apply(connection, table, ids)
+
+
+@event.listens_for(Session, "after_soft_rollback")
+def _forget_pending(session: Session, previous_transaction: Any) -> None:
+    # A flush that failed after collecting must not leave ids for the next one.
+    session.info.pop(_PENDING, None)
+
+
+@event.listens_for(Session, "do_orm_execute")
+def _before_bulk_delete(state: ORMExecuteState) -> None:
+    statement = state.statement
+    if not state.is_delete or not isinstance(statement, Delete):
+        return
+    table = statement.table
+    if not isinstance(table, Table) or not _rules(table.name):
+        return
+    key = _single_primary_key(table)
+    if key is None:
+        return
+    connection = state.session.connection()
+    if not _applies(connection):
+        return
+    # The session would flush pending changes only after this hook; flush now so
+    # the rules see them (a queue item just moved off the archive must not go).
+    if state.session.autoflush:
+        state.session.flush()
+    where = statement.whereclause
+    _apply(connection, table.name, select(key).where(where) if where is not None else select(key))
+
+
+def _still_not_null(connection: Connection, column: Column) -> bool:
+    """Whether the column is NOT NULL in the database, whatever the model says."""
+    rows = connection.execute(text(f'PRAGMA table_info("{column.table.name}")'))
+    return any(row[1] == column.name and row[3] for row in rows)
+
+
+async def sweep_orphans(conn: Any) -> None:
+    """Repair APPLIED references to rows that no longer exist (SQLite only).
+
+    A SET NULL reference to a missing row is cleared and a CASCADE row is
+    deleted, which is what the rule would have done at the delete. Repeated
+    until a pass changes nothing, since a deleted row can leave its own
+    dependents behind.
+    """
+
+    def run(sync: Any) -> None:
+        connection = sync.connection() if isinstance(sync, Session) else sync
+        if not _applies(connection):
+            return
+        tables = _existing_tables(connection)
+        rules = [
+            fk for fk in _applied_foreign_keys() if fk.parent.table.name in tables and fk.column.table.name in tables
+        ]
+        refused = {fk for fk in rules if fk.ondelete == "SET NULL" and _still_not_null(connection, fk.parent)}
+        for fk in refused:
+            # A migration that would have dropped it failed (and said so); clearing
+            # the column would fail too, and take the start of the app with it.
+            logger.warning("Not clearing %s.%s: the column is still NOT NULL", fk.parent.table.name, fk.parent.name)
+        rules = [fk for fk in rules if fk not in refused]
+        for _ in range(10):
+            changed = 0
+            for fk in rules:
+                child, column, referenced = fk.parent.table, fk.parent, fk.column
+                missing = column.is_not(None) & ~exists(
+                    select(1).select_from(referenced.table).where(referenced == column)
+                )
+                if fk.ondelete == "SET NULL":
+                    result = connection.execute(update(child).where(missing).values({column.name: None}))
+                else:
+                    result = connection.execute(delete(child).where(missing))
+                if result.rowcount:
+                    changed += result.rowcount
+                    logger.info(
+                        "%s %d %s row(s) whose %s no longer exists",
+                        "Cleared" if fk.ondelete == "SET NULL" else "Deleted",
+                        result.rowcount,
+                        child.name,
+                        referenced.table.name,
+                    )
+            if not changed:
+                return
+
+    await conn.run_sync(run)

+ 96 - 8
backend/app/core/websocket.py

@@ -31,6 +31,31 @@ def _may_receive(connection: WebSocket, printer_id: int | None) -> bool:
     return scope is not None and scope.allows(printer_id)
 
 
+# A printer id no limited scope holds: what an archive event carries when its
+# printer cannot be told, so only sockets that see every printer receive it.
+_UNKNOWN_PRINTER = -1
+
+
+async def _archive_printer_id(archive_id: Any) -> int | None:
+    """The printer an archive belongs to (None: printer-less, seen by everyone)."""
+    from sqlalchemy import select
+
+    from backend.app.core.database import async_session
+    from backend.app.models.archive import PrintArchive
+
+    if not isinstance(archive_id, int):
+        return _UNKNOWN_PRINTER
+    try:
+        async with async_session() as db:
+            row = (await db.execute(select(PrintArchive.printer_id).where(PrintArchive.id == archive_id))).first()
+    except Exception:
+        logger.exception("Could not look up the printer of archive %s for a live update", archive_id)
+        return _UNKNOWN_PRINTER
+    if row is None:
+        return _UNKNOWN_PRINTER
+    return row[0]
+
+
 class ConnectionManager:
     """Manages WebSocket connections and broadcasts."""
 
@@ -109,6 +134,41 @@ class ConnectionManager:
                 if conn in self.active_connections:
                     self.active_connections.remove(conn)
 
+    async def close_user(self, user_id: int):
+        """Close a deleted user's sockets, which would otherwise keep receiving
+        the messages of whoever is given their id next."""
+        await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) == user_id)
+
+    async def close_signed_in(self):
+        """Close every signed-in user's socket, after everyone is signed out (a
+        backup restore). API-key and anonymous sockets stay: nothing ended
+        theirs."""
+        await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) is not None)
+
+    async def close_anonymous(self):
+        """Close the sockets opened while authentication was off, which have
+        every printer, once it is turned on."""
+        await self._close(
+            lambda c: getattr(c.state, "bambuddy_scope_principal", (None, None)) in ((None, None), ("", None))
+        )
+
+    async def close_api_keys(self, key_ids):
+        """Close the sockets opened with deleted API keys. Their scope is looked
+        up by key id on every refresh: SQLite gives the id to the next key."""
+        key_ids = set(key_ids)
+        await self._close(lambda c: getattr(c.state, "bambuddy_scope_principal", (None, None))[1] in key_ids)
+
+    async def _close(self, matches):
+        async with self._lock:
+            theirs = [c for c in self.active_connections if matches(c)]
+            for connection in theirs:
+                self.active_connections.remove(connection)
+        for connection in theirs:
+            try:
+                await connection.close(code=4401)
+            except Exception:  # noqa: BLE001 -- already gone
+                pass
+
     async def refresh_printer_scopes(self):
         """Recompute every connection's printer scope (#1727).
 
@@ -116,7 +176,12 @@ class ConnectionManager:
         is in a group, so open dashboards stop (or start) receiving those
         printers' events without a reconnect.
         """
-        from backend.app.core.auth import is_auth_enabled, principal_printer_scope
+        from backend.app.core.auth import (
+            is_auth_enabled,
+            principal_may_connect,
+            principal_printer_scope,
+            usable_api_key_prefix,
+        )
         from backend.app.core.database import async_session
         from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 
@@ -124,6 +189,8 @@ class ConnectionManager:
             connections = list(self.active_connections)
         if not connections:
             return
+
+        orphaned = []
         try:
             async with async_session() as db:
                 auth_enabled = await is_auth_enabled(db)
@@ -132,6 +199,22 @@ class ConnectionManager:
                         connection.state.bambuddy_printer_scope = ALL_PRINTERS
                         continue
                     username, api_key_id = getattr(connection.state, "bambuddy_scope_principal", (None, None))
+                    if api_key_id is not None:
+                        # The key is looked up by id, which SQLite gives to the
+                        # next key created: a socket whose key is gone, or whose
+                        # id another key now has, is closed, not re-scoped (a key
+                        # rotated from the command line, which can't close it).
+                        # Disabled or expired counts as gone: REST refuses it.
+                        prefix = await usable_api_key_prefix(db, api_key_id)
+                        if prefix is None or prefix != getattr(connection.state, "bambuddy_api_key_prefix", prefix):
+                            connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
+                            orphaned.append(connection)
+                            continue
+                    if not await principal_may_connect(db, username, api_key_id):
+                        # websocket:connect was taken away (a group or key change)
+                        connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
+                        orphaned.append(connection)
+                        continue
                     connection.state.bambuddy_printer_scope = await principal_printer_scope(db, username, api_key_id)
         except Exception:  # SEC-AUTH-EXC: refresh failed → fail closed (empty scope, then disconnect to re-auth)
             # The old scopes may be wider than what was just granted, so they
@@ -145,6 +228,9 @@ class ConnectionManager:
                     await connection.close(code=4401)
                 except Exception:  # noqa: BLE001 -- already gone; disconnect() cleans it up
                     pass
+            return
+        if orphaned:
+            await self._close(lambda c: c in orphaned)
 
     async def send_printer_status(self, printer_id: int, status: dict):
         """Send printer status update to all clients."""
@@ -196,13 +282,15 @@ class ConnectionManager:
         )
 
     async def send_archive_updated(self, archive: dict):
-        """Notify clients that an archive was updated."""
-        await self.broadcast(
-            {
-                "type": "archive_updated",
-                "data": archive,
-            }
-        )
+        """Notify clients that an archive was updated.
+
+        Only sockets that may see the archive's printer receive it: callers send
+        just the changed fields, so the printer is looked up here when missing.
+        """
+        message: dict[str, Any] = {"type": "archive_updated", "data": archive}
+        if _message_printer_id(message) is None:
+            message["printer_id"] = await _archive_printer_id(archive.get("id"))
+        await self.broadcast(message)
 
     async def send_queue_item_uploading(
         self,

+ 0 - 125
backend/app/i18n/__init__.py

@@ -1,125 +0,0 @@
-"""Internationalization module for backend notifications."""
-
-from typing import Any
-
-# English translations
-EN = {
-    "notification": {
-        # Print events
-        "print_started": "Print Started",
-        "print_completed": "Print Completed",
-        "print_failed": "Print Failed",
-        "print_stopped": "Print Stopped",
-        "print_ended": "Print Ended",
-        "print_progress": "Print {progress}% Complete",
-        "estimated": "Estimated",
-        "time": "Time",
-        "filament": "Filament",
-        "reason": "Reason",
-        "unknown": "Unknown",
-        # Printer events
-        "printer_offline": "Printer Offline",
-        "printer_disconnected": "{printer} has disconnected",
-        "printer_error": "Printer Error: {error_type}",
-        # Filament
-        "filament_low": "Filament Low",
-        "slot_at_percent": "{printer}: Slot {slot} at {percent}%",
-        # Maintenance
-        "maintenance_due": "Maintenance Due",
-        "overdue": "OVERDUE",
-        "soon": "Soon",
-        # Test notification
-        "test_title": "Bambuddy Test",
-        "test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!",
-    }
-}
-
-# German translations
-DE = {
-    "notification": {
-        # Print events
-        "print_started": "Druck gestartet",
-        "print_completed": "Druck abgeschlossen",
-        "print_failed": "Druck fehlgeschlagen",
-        "print_stopped": "Druck gestoppt",
-        "print_ended": "Druck beendet",
-        "print_progress": "Druck {progress}% fertig",
-        "estimated": "Geschätzt",
-        "time": "Zeit",
-        "filament": "Filament",
-        "reason": "Grund",
-        "unknown": "Unbekannt",
-        # Printer events
-        "printer_offline": "Drucker offline",
-        "printer_disconnected": "{printer} wurde getrennt",
-        "printer_error": "Druckerfehler: {error_type}",
-        # Filament
-        "filament_low": "Wenig Filament",
-        "slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
-        # Maintenance
-        "maintenance_due": "Wartung fällig",
-        "overdue": "ÜBERFÄLLIG",
-        "soon": "Bald",
-        # Test notification
-        "test_title": "Bambuddy Test",
-        "test_message": "Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
-    }
-}
-
-# All available translations
-TRANSLATIONS = {
-    "en": EN,
-    "de": DE,
-}
-
-
-def get_translation(lang: str, key: str, **kwargs: Any) -> str:
-    """
-    Get a translation string by key with optional interpolation.
-
-    Args:
-        lang: Language code (e.g., 'en', 'de')
-        key: Dot-separated key path (e.g., 'notification.print_started')
-        **kwargs: Values to interpolate into the string
-
-    Returns:
-        Translated string, or the key if not found
-    """
-    # Fall back to English if language not found
-    translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"])
-
-    # Navigate to the nested key
-    keys = key.split(".")
-    value = translations
-    for k in keys:
-        if isinstance(value, dict) and k in value:
-            value = value[k]
-        else:
-            # Key not found, fall back to English
-            value = TRANSLATIONS["en"]
-            for k2 in keys:
-                if isinstance(value, dict) and k2 in value:
-                    value = value[k2]
-                else:
-                    return key  # Return key if not found in fallback either
-            break
-
-    if isinstance(value, str):
-        # Interpolate values
-        try:
-            return value.format(**kwargs)
-        except KeyError:
-            return value
-
-    return key
-
-
-class Translator:
-    """Helper class for translations with a specific language."""
-
-    def __init__(self, lang: str = "en"):
-        self.lang = lang if lang in TRANSLATIONS else "en"
-
-    def t(self, key: str, **kwargs: Any) -> str:
-        """Translate a key."""
-        return get_translation(self.lang, key, **kwargs)

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 414 - 208
backend/app/main.py


+ 13 - 9
backend/app/models/api_key.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, false, func, true
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -10,6 +10,8 @@ class APIKey(Base):
     """API key for external webhook access."""
 
     __tablename__ = "api_keys"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))  # User-friendly name
@@ -31,30 +33,32 @@ class APIKey(Base):
     can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
     can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
     can_manage_library: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Upload/rename/delete own library files + MakerWorld import
     can_manage_inventory: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Inventory write ops (incl. SpoolBuddy kiosk NFC/scale/system)
     can_manage_maintenance: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Log/reset per-printer maintenance, edit intervals, manage the type catalog (#1832 follow-up)
     can_manage_archives: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Create/update/delete print archives (not purge) (#1888)
     can_manage_projects: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Create/update/delete projects + manage membership (add archives) (#1893)
-    can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
+    can_access_cloud: Mapped[bool] = mapped_column(
+        Boolean, default=False, server_default=false()
+    )  # Read /cloud/* on the owner's behalf
     # Narrowly-scoped settings write: only POST /settings/electricity-price.
     # Lets HA/Tibber-style automations push dynamic tariff updates without
     # granting full SETTINGS_UPDATE (which is denied for API keys because it
     # could rewrite SMTP/LDAP/MQTT credentials).
-    can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False)
+    can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     # Send a message through the notification channels that accept app
     # messages (POST /notifications/app-message). Nothing else: no reading or
     # changing the channels themselves.
-    can_send_notifications: Mapped[bool] = mapped_column(Boolean, default=False)
+    can_send_notifications: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Optional scope limits
     printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers

+ 4 - 2
backend/app/models/archive.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
+from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -8,6 +8,8 @@ from backend.app.core.database import Base
 
 class PrintArchive(Base):
     __tablename__ = "print_archives"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
@@ -104,7 +106,7 @@ class PrintArchive(Base):
 
     # User additions
     is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
-    wallet_charge_skipped: Mapped[bool] = mapped_column(Boolean, default=False)
+    wallet_charge_skipped: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     tags: Mapped[str | None] = mapped_column(Text)
     notes: Mapped[str | None] = mapped_column(Text)
     cost: Mapped[float | None] = mapped_column(Float)

+ 12 - 90
backend/app/models/auth_ephemeral.py

@@ -43,6 +43,12 @@ class TokenType(str, Enum):
     EMAIL_OTP_SETUP = "email_otp_setup"
     SLICER_DOWNLOAD = "slicer_download"
     CONNECT_CODE = "connect_code"
+    # Written as bare strings where they are used; listed here so every kind is
+    # known (see test_token_outlived_session).
+    WEBSOCKET = "websocket"
+    CAMERA_STREAM = "camera_stream"
+    MEDIA = "media"
+    REVOKED_JTI = "revoked_jti"  # the logout list, not a credential
 
 
 class EventType(str, Enum):
@@ -91,9 +97,9 @@ class AuthEphemeralToken(Base):
     # (XSS can read JS memory but cannot read HttpOnly cookies).
     challenge_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
 
-    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+    expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
     created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True),
+        DateTime,
         nullable=False,
         default=lambda: datetime.now(timezone.utc),
     )
@@ -103,93 +109,6 @@ class AuthEphemeralToken(Base):
     # and prevent accidentally leaving optional fields at their defaults.
     # ------------------------------------------------------------------
 
-    @classmethod
-    def new_pre_auth(
-        cls,
-        token: str,
-        username: str,
-        expires_at: datetime,
-        challenge_id: str | None = None,
-    ) -> AuthEphemeralToken:
-        """Create a pre-auth token (issued after password check, before 2FA)."""
-        return cls(
-            token=token,
-            token_type=TokenType.PRE_AUTH,
-            username=username,
-            expires_at=expires_at,
-            challenge_id=challenge_id,
-        )
-
-    @classmethod
-    def new_oidc_state(
-        cls,
-        token: str,
-        provider_id: int,
-        nonce: str,
-        code_verifier: str,
-        expires_at: datetime,
-    ) -> AuthEphemeralToken:
-        """Create an OIDC state token (CSRF protection + PKCE for authorize redirect)."""
-        return cls(
-            token=token,
-            token_type=TokenType.OIDC_STATE,
-            provider_id=provider_id,
-            nonce=nonce,
-            code_verifier=code_verifier,
-            expires_at=expires_at,
-        )
-
-    @classmethod
-    def new_oidc_exchange(
-        cls,
-        token: str,
-        username: str,
-        expires_at: datetime,
-    ) -> AuthEphemeralToken:
-        """Create an OIDC exchange token (bridge from callback to SPA)."""
-        return cls(
-            token=token,
-            token_type=TokenType.OIDC_EXCHANGE,
-            username=username,
-            expires_at=expires_at,
-        )
-
-    @classmethod
-    def new_password_reset(
-        cls,
-        token: str,
-        username: str,
-        expires_at: datetime,
-    ) -> AuthEphemeralToken:
-        """Create a password-reset token (single-use link emailed to the user)."""
-        return cls(
-            token=token,
-            token_type=TokenType.PASSWORD_RESET,
-            username=username,
-            expires_at=expires_at,
-        )
-
-    @classmethod
-    def new_email_otp_setup(
-        cls,
-        token: str,
-        username: str,
-        code_hash: str,
-        expires_at: datetime,
-    ) -> AuthEphemeralToken:
-        """Create an email-OTP setup token.
-
-        The ``code_hash`` is stored in the ``nonce`` column (field reuse
-        documented inline in the enable_email_otp endpoint).
-        """
-        return cls(
-            token=token,
-            token_type=TokenType.EMAIL_OTP_SETUP,
-            username=username,
-            nonce=code_hash,
-            expires_at=expires_at,
-        )
-
     @classmethod
     def new_connect_code(
         cls,
@@ -198,6 +117,7 @@ class AuthEphemeralToken(Base):
         app_id: int,
         code_challenge: str,
         expires_at: datetime,
+        created_at: datetime | None = None,
     ) -> AuthEphemeralToken:
         """Create a connected-app authorization code.
 
@@ -212,6 +132,8 @@ class AuthEphemeralToken(Base):
             provider_id=app_id,
             nonce=code_challenge,
             expires_at=expires_at,
+            # When the minting session began (core/auth.session_dated), if given
+            **({"created_at": created_at} if created_at is not None else {}),
         )
 
 
@@ -224,7 +146,7 @@ class AuthRateLimitEvent(Base):
     username: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
     event_type: Mapped[str] = mapped_column(String(20), nullable=False)  # '2fa_attempt' | 'email_send'
     occurred_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True),
+        DateTime,
         nullable=False,
         default=lambda: datetime.now(timezone.utc),
     )

+ 4 - 2
backend/app/models/finance.py

@@ -133,7 +133,9 @@ class WalletTransaction(Base):
     )
 
     id: Mapped[int] = mapped_column(primary_key=True)
-    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    # SET NULL, not CASCADE: a deleted user's entries stay in the ledger, or what
+    # they spent of a shared cost centre's budget would be freed again.
+    user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
     cost_center_id: Mapped[int | None] = mapped_column(
         ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
     )
@@ -161,7 +163,7 @@ class WalletTransaction(Base):
 
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
 
-    user: Mapped[User] = relationship(foreign_keys=[user_id])
+    user: Mapped[User | None] = relationship(foreign_keys=[user_id])
     cost_center: Mapped[CostCenter | None] = relationship()
     created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
     print_archive: Mapped[PrintArchive | None] = relationship()

+ 3 - 3
backend/app/models/github_backup.py

@@ -2,7 +2,7 @@
 
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -17,8 +17,8 @@ class GitHubBackupConfig(Base):
     repository_url: Mapped[str] = mapped_column(String(500))  # Full GitHub URL
     access_token: Mapped[str] = mapped_column(Text)  # Personal Access Token
     branch: Mapped[str] = mapped_column(String(100), default="main")
-    provider: Mapped[str] = mapped_column(String(30), default="github")
-    allow_insecure_http: Mapped[bool] = mapped_column(Boolean, default=False)
+    provider: Mapped[str] = mapped_column(String(30), default="github", server_default="github")
+    allow_insecure_http: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Schedule configuration
     schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)

+ 1 - 1
backend/app/models/library.py

@@ -218,7 +218,7 @@ class LibraryFile(Base):
     )
 
     @classmethod
-    def active(cls) -> "Select[tuple[LibraryFile]]":
+    def active(cls) -> "Select[LibraryFile]":
         """Select statement that excludes trashed (soft-deleted) files.
 
         Use this in place of ``select(LibraryFile)`` for any user-facing listing

+ 2 - 0
backend/app/models/long_lived_token.py

@@ -45,6 +45,8 @@ class LongLivedToken(Base):
     """Per-user, hashed-at-rest, revocable token for long-running camera viewers."""
 
     __tablename__ = "long_lived_tokens"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
     user_id: Mapped[int] = mapped_column(

+ 3 - 1
backend/app/models/maintenance.py

@@ -2,7 +2,7 @@
 
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, String, Text, func
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -35,6 +35,8 @@ class PrinterMaintenance(Base):
     """Tracks maintenance status for a specific printer."""
 
     __tablename__ = "printer_maintenance"
+    # One row per printer and type: they are created on first read (FO-C14).
+    __table_args__ = (Index("uq_printer_maintenance_printer_type", "printer_id", "maintenance_type_id", unique=True),)
 
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))

+ 145 - 89
backend/app/models/notification.py

@@ -1,29 +1,37 @@
 """Notification provider and log models for push notifications."""
 
 from datetime import datetime
+from typing import TYPE_CHECKING
 
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
-from sqlalchemy.orm import relationship
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, false, true
+from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
+if TYPE_CHECKING:
+    from backend.app.models.printer import Printer
+
 
 class NotificationDigestQueue(Base):
     """Model for queuing notifications to be sent in daily digest."""
 
     __tablename__ = "notification_digest_queue"
 
-    id = Column(Integer, primary_key=True, index=True)
-    provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
-    event_type = Column(String(50), nullable=False)  # print_start, print_complete, etc.
-    title = Column(String(255), nullable=False)
-    message = Column(Text, nullable=False)
-    printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
-    printer_name = Column(String(100), nullable=True)
-    created_at = Column(DateTime, default=datetime.utcnow, index=True)
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+    provider_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
+    )
+    event_type: Mapped[str] = mapped_column(String(50), nullable=False)  # print_start, print_complete, etc.
+    title: Mapped[str] = mapped_column(String(255), nullable=False)
+    message: Mapped[str] = mapped_column(Text, nullable=False)
+    printer_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
+    )
+    printer_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+    created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
 
     # Relationships
-    provider = relationship("NotificationProvider", back_populates="digest_queue")
+    provider: Mapped["NotificationProvider"] = relationship(back_populates="digest_queue")
 
 
 class NotificationLog(Base):
@@ -31,19 +39,25 @@ class NotificationLog(Base):
 
     __tablename__ = "notification_logs"
 
-    id = Column(Integer, primary_key=True, index=True)
-    provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
-    event_type = Column(String(50), nullable=False)  # print_start, print_complete, etc.
-    title = Column(String(255), nullable=False)
-    message = Column(Text, nullable=False)
-    success = Column(Boolean, default=True)
-    error_message = Column(Text, nullable=True)
-    printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
-    printer_name = Column(String(100), nullable=True)  # Store name in case printer is deleted
-    created_at = Column(DateTime, default=datetime.utcnow, index=True)
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+    provider_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
+    )
+    event_type: Mapped[str] = mapped_column(String(50), nullable=False)  # print_start, print_complete, etc.
+    title: Mapped[str] = mapped_column(String(255), nullable=False)
+    message: Mapped[str] = mapped_column(Text, nullable=False)
+    success: Mapped[bool | None] = mapped_column(Boolean, default=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    printer_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
+    )
+    printer_name: Mapped[str | None] = mapped_column(
+        String(100), nullable=True
+    )  # Store name in case printer is deleted
+    created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
 
     # Relationships
-    provider = relationship("NotificationProvider", back_populates="logs")
+    provider: Mapped["NotificationProvider"] = relationship(back_populates="logs")
 
 
 class NotificationProvider(Base):
@@ -51,120 +65,158 @@ class NotificationProvider(Base):
 
     __tablename__ = "notification_providers"
 
-    id = Column(Integer, primary_key=True, index=True)
-    name = Column(String(100), nullable=False)  # User-defined name
-    provider_type = Column(String(50), nullable=False)  # callmebot, ntfy, pushover, telegram, email
-    enabled = Column(Boolean, default=True)
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+    name: Mapped[str] = mapped_column(String(100), nullable=False)  # User-defined name
+    provider_type: Mapped[str] = mapped_column(String(50), nullable=False)  # callmebot, ntfy, pushover, telegram, email
+    enabled: Mapped[bool | None] = mapped_column(Boolean, default=True)
 
     # Provider-specific configuration stored as JSON string
-    config = Column(Text, nullable=False)
+    config: Mapped[str] = mapped_column(Text, nullable=False)
 
     # Attach a camera snapshot when one was captured for the event. Defaults
     # True so ntfy/Pushover/Telegram/Discord keep behaving as they always
     # did; this is the opt-out for whoever doesn't want it.
-    attach_photo = Column(Boolean, default=True)
+    attach_photo: Mapped[bool | None] = mapped_column(Boolean, default=True, server_default=true())
 
     # Event triggers - print lifecycle
-    on_print_start = Column(Boolean, default=False)
-    on_print_complete = Column(Boolean, default=True)
-    on_print_failed = Column(Boolean, default=True)
-    on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
-    on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
-    on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)
-    on_billing_charge_failed = Column(Boolean, default=True)  # A completed/stopped print could not be charged
+    on_print_start: Mapped[bool | None] = mapped_column(Boolean, default=False)
+    on_print_complete: Mapped[bool | None] = mapped_column(Boolean, default=True)
+    on_print_failed: Mapped[bool | None] = mapped_column(Boolean, default=True)
+    on_print_stopped: Mapped[bool | None] = mapped_column(Boolean, default=True)  # User cancelled/stopped print
+    on_print_progress: Mapped[bool | None] = mapped_column(Boolean, default=False)  # 25%, 50%, 75% milestones
+    on_print_missing_spool_assignment: Mapped[bool | None] = mapped_column(
+        Boolean, default=False
+    )  # Print started with unassigned required tray(s)
+    on_billing_charge_failed: Mapped[bool | None] = mapped_column(
+        Boolean, default=True, server_default=true()
+    )  # A completed/stopped print could not be charged
 
     # Event triggers - printer status
-    on_printer_offline = Column(Boolean, default=False)
-    on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
-    on_ai_failure_detection = Column(Boolean, default=False)  # Obico spaghetti / failure detection (#1794)
-    on_filament_low = Column(Boolean, default=False)
-    on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
+    on_printer_offline: Mapped[bool | None] = mapped_column(Boolean, default=False)
+    on_printer_error: Mapped[bool | None] = mapped_column(Boolean, default=False)  # AMS issues, etc.
+    on_ai_failure_detection: Mapped[bool | None] = mapped_column(
+        Boolean, default=False, server_default=false()
+    )  # Obico spaghetti / failure detection (#1794)
+    on_filament_low: Mapped[bool | None] = mapped_column(Boolean, default=False)
+    on_maintenance_due: Mapped[bool | None] = mapped_column(Boolean, default=False)  # Maintenance reminder
 
     # Event triggers - AMS environmental alarms (regular AMS with 4 slots)
-    on_ams_humidity_high = Column(Boolean, default=False)  # AMS humidity above threshold
-    on_ams_temperature_high = Column(Boolean, default=False)  # AMS temperature above threshold
+    on_ams_humidity_high: Mapped[bool | None] = mapped_column(Boolean, default=False)  # AMS humidity above threshold
+    on_ams_temperature_high: Mapped[bool | None] = mapped_column(
+        Boolean, default=False
+    )  # AMS temperature above threshold
     # Auto-drying gave up on a unit (#2770). Defaults True: it reports that
     # Bambuddy has stopped acting, which nothing else in the UI would say.
-    on_ams_drying_suspended = Column(Boolean, default=True)
+    on_ams_drying_suspended: Mapped[bool | None] = mapped_column(Boolean, default=True, server_default=true())
 
     # Event triggers - AMS-HT environmental alarms (single slot heated AMS)
-    on_ams_ht_humidity_high = Column(Boolean, default=False)  # AMS-HT humidity above threshold
-    on_ams_ht_temperature_high = Column(Boolean, default=False)  # AMS-HT temperature above threshold
+    on_ams_ht_humidity_high: Mapped[bool | None] = mapped_column(
+        Boolean, default=False
+    )  # AMS-HT humidity above threshold
+    on_ams_ht_temperature_high: Mapped[bool | None] = mapped_column(
+        Boolean, default=False
+    )  # AMS-HT temperature above threshold
 
     # Event triggers - Home Assistant sensors bound to a printer (#1148)
-    on_ha_sensor_alert = Column(Boolean, default=False)  # Bound HA sensor entered its alert state
+    on_ha_sensor_alert: Mapped[bool | None] = mapped_column(
+        Boolean, default=False, server_default=false()
+    )  # Bound HA sensor entered its alert state
 
     # Event triggers - Home Assistant sensors bound to a storage location (#2824)
     # Its own column rather than reusing on_ha_sensor_alert above: that one can
     # be scoped to a single printer, and a location alert has no printer to
     # scope by, so sharing it would leak drybox alerts to a provider narrowed
     # to one printer's sensors.
-    on_location_ha_sensor_alert = Column(Boolean, default=False)
+    on_location_ha_sensor_alert: Mapped[bool | None] = mapped_column(Boolean, default=False, server_default=false())
 
     # Event triggers - Build plate detection
-    on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
+    on_plate_not_empty: Mapped[bool | None] = mapped_column(
+        Boolean, default=True
+    )  # Objects detected on plate before print
     # Off by default: fires after every print, alongside the print-complete alert (#2525)
-    on_plate_clear_required = Column(Boolean, default=False)  # Print ended, queue gated until plate is confirmed clear
+    on_plate_clear_required: Mapped[bool | None] = mapped_column(
+        Boolean, default=False, server_default=false()
+    )  # Print ended, queue gated until plate is confirmed clear
 
     # Print asked for an outcome verdict (#1898). Defaults ON: it only ever
     # fires for prints where the user opted in per-job, so the provider-level
     # toggle exists to silence a channel, not to enable the feature.
-    on_print_confirm_request = Column(Boolean, default=True)
+    on_print_confirm_request: Mapped[bool | None] = mapped_column(Boolean, default=True, server_default=true())
     # How a Telegram provider collects that verdict (#3046): inline URL
     # buttons ("buttons", the original behaviour), a thumbs-up/down reaction
     # on the message ("reactions"), or both. Reactions need no inbound
     # connectivity — the poller fetches them — so they work without an
     # external_url. Ignored for every other provider type.
-    telegram_verdict_mode = Column(String(16), default="buttons")
+    telegram_verdict_mode: Mapped[str | None] = mapped_column(String(16), default="buttons", server_default="buttons")
 
     # Event triggers - Bed cooled after print
-    on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
-    on_first_layer_complete = Column(Boolean, default=False)  # First layer finished printing
+    on_bed_cooled: Mapped[bool | None] = mapped_column(Boolean, default=False)  # Bed cooled below threshold after print
+    on_first_layer_complete: Mapped[bool | None] = mapped_column(
+        Boolean, default=False
+    )  # First layer finished printing
     # Messages another application sends through Bambuddy (POST /notifications/app-message),
     # e.g. Bambuddy Orders' "an order needs you". Off by default: nothing new arrives on upgrade.
-    on_app_message = Column(Boolean, default=False)
+    on_app_message: Mapped[bool | None] = mapped_column(Boolean, default=False, server_default=false())
 
     # Event triggers - Inventory stock alerts
-    on_stock_reorder_alert = Column(Boolean, default=False)  # SKU hits reorder point
-    on_stock_break_alert = Column(Boolean, default=False)  # Stock will run out before replenishment
+    on_stock_reorder_alert: Mapped[bool | None] = mapped_column(
+        Boolean, default=False, server_default=false()
+    )  # SKU hits reorder point
+    on_stock_break_alert: Mapped[bool | None] = mapped_column(
+        Boolean, default=False, server_default=false()
+    )  # Stock will run out before replenishment
 
     # Event triggers - Print queue
-    on_queue_job_added = Column(Boolean, default=False)  # Job added to queue
-    on_queue_job_assigned = Column(Boolean, default=False)  # Model-based job assigned to printer
-    on_queue_job_started = Column(Boolean, default=False)  # Queue job started printing
-    on_queue_job_waiting = Column(Boolean, default=True)  # Job waiting for filament or printer
-    on_queue_job_skipped = Column(Boolean, default=True)  # Job skipped (previous print failed)
-    on_queue_job_failed = Column(Boolean, default=True)  # Job failed to start
-    on_queue_completed = Column(Boolean, default=False)  # All pending jobs finished
+    on_queue_job_added: Mapped[bool | None] = mapped_column(Boolean, default=False)  # Job added to queue
+    on_queue_job_assigned: Mapped[bool | None] = mapped_column(
+        Boolean, default=False
+    )  # Model-based job assigned to printer
+    on_queue_job_started: Mapped[bool | None] = mapped_column(Boolean, default=False)  # Queue job started printing
+    on_queue_job_waiting: Mapped[bool | None] = mapped_column(
+        Boolean, default=True
+    )  # Job waiting for filament or printer
+    on_queue_job_skipped: Mapped[bool | None] = mapped_column(
+        Boolean, default=True
+    )  # Job skipped (previous print failed)
+    on_queue_job_failed: Mapped[bool | None] = mapped_column(Boolean, default=True)  # Job failed to start
+    on_queue_completed: Mapped[bool | None] = mapped_column(Boolean, default=False)  # All pending jobs finished
 
     # Quiet hours (do not disturb)
-    quiet_hours_enabled = Column(Boolean, default=False)
-    quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"
-    quiet_hours_end = Column(String(5), nullable=True)  # HH:MM format, e.g., "07:00"
+    quiet_hours_enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
+    quiet_hours_start: Mapped[str | None] = mapped_column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"
+    quiet_hours_end: Mapped[str | None] = mapped_column(String(5), nullable=True)  # HH:MM format, e.g., "07:00"
 
     # Daily digest (batch notifications into a single daily summary)
-    daily_digest_enabled = Column(Boolean, default=False)
-    daily_digest_time = Column(String(5), nullable=True)  # HH:MM format, e.g., "08:00"
+    daily_digest_enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
+    daily_digest_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # HH:MM format, e.g., "08:00"
 
-    # Optional: Link to specific printer (NULL = all printers)
-    printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
+    # Optional: Link to specific printer (NULL = all printers). CASCADE, not SET
+    # NULL: a provider limited to a deleted printer must not become one for all
+    # of them. The printer delete route removes it first (remote cleanup).
+    printer_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("printers.id", ondelete="CASCADE"), nullable=True
+    )
 
     # Status tracking
-    last_success = Column(DateTime, nullable=True)
-    last_error = Column(Text, nullable=True)
-    last_error_at = Column(DateTime, nullable=True)
+    last_success: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+    last_error_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
 
     # Timestamps
-    created_at = Column(DateTime, default=datetime.utcnow)
-    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow)
+    updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
 
     # Relationships
-    printer = relationship("Printer", back_populates="notification_providers")
-    logs = relationship("NotificationLog", back_populates="provider", cascade="all, delete-orphan")
-    digest_queue = relationship("NotificationDigestQueue", back_populates="provider", cascade="all, delete-orphan")
+    printer: Mapped["Printer | None"] = relationship(back_populates="notification_providers")
+    logs: Mapped[list["NotificationLog"]] = relationship(back_populates="provider", cascade="all, delete-orphan")
+    digest_queue: Mapped[list["NotificationDigestQueue"]] = relationship(
+        back_populates="provider", cascade="all, delete-orphan"
+    )
     # ORM-level cascade like the two above: SQLite ships with foreign keys
     # off, so the column's ON DELETE CASCADE alone would leave rows behind.
-    pending_verdicts = relationship("TelegramPendingVerdict", back_populates="provider", cascade="all, delete-orphan")
+    pending_verdicts: Mapped[list["TelegramPendingVerdict"]] = relationship(
+        back_populates="provider", cascade="all, delete-orphan"
+    )
 
 
 class TelegramPendingVerdict(Base):
@@ -178,22 +230,26 @@ class TelegramPendingVerdict(Base):
 
     __tablename__ = "telegram_pending_verdicts"
 
-    id = Column(Integer, primary_key=True, index=True)
-    provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
-    chat_id = Column(String(64), nullable=False)
-    message_id = Column(Integer, nullable=False)
-    archive_id = Column(Integer, ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=False)
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+    provider_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
+    )
+    chat_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    message_id: Mapped[int] = mapped_column(Integer, nullable=False)
+    archive_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=False
+    )
     # The archive's confirm_token when the prompt went out. A reaction counts
     # only while that is still the archive's live token, i.e. exactly as long
     # as the prompt's one-tap links would: a reprint clears the token, so an
     # earlier run's message can no longer answer the new run.
-    confirm_token = Column(String(64), nullable=True)
+    confirm_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
     # sendPhoto messages carry the prompt as a caption, which is edited with a
     # different Bot API method than a plain text message.
-    has_caption = Column(Boolean, default=False)
+    has_caption: Mapped[bool | None] = mapped_column(Boolean, default=False)
     # The Markdown we sent, so the confirmation edit can append to it — the
     # reaction update does not carry the message body.
-    message_text = Column(Text, nullable=True)
-    created_at = Column(DateTime, default=datetime.utcnow, index=True)
+    message_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+    created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
 
-    provider = relationship("NotificationProvider", back_populates="pending_verdicts")
+    provider: Mapped["NotificationProvider"] = relationship(back_populates="pending_verdicts")

+ 20 - 19
backend/app/models/notification_live_activity.py

@@ -2,7 +2,8 @@
 
 from datetime import datetime
 
-from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
 
@@ -11,24 +12,24 @@ class NotificationLiveActivity(Base):
     __tablename__ = "notification_live_activities"
     __table_args__ = (UniqueConstraint("provider_id", "printer_id", "print_key"),)
 
-    id = Column(Integer, primary_key=True)
-    provider_id = Column(
+    id: Mapped[int] = mapped_column(Integer, primary_key=True)
+    provider_id: Mapped[int] = mapped_column(
         Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False, index=True
     )
     # Keep ownership after printer deletion until the worker ends the remote tile.
-    printer_id = Column(Integer, nullable=False, index=True)
-    print_key = Column(String(200), nullable=False)
-    job_key = Column(String(160), nullable=True)
-    print_name = Column(String(255), nullable=False, default="")
-    eta_seconds = Column(Integer, nullable=True)
-    eta_deadline = Column(DateTime, nullable=True)
-    credential_key = Column(String(64), nullable=False)
-    activity_id = Column(String(16), nullable=True)
-    state = Column(String(20), nullable=False, default="pending")
-    end_reason = Column(String(40), nullable=True)
-    failures = Column(Integer, nullable=False, default=0)
-    content = Column(Text, nullable=False, default="{}")
-    expires_at = Column(DateTime, nullable=True)
-    next_attempt_at = Column(DateTime, nullable=True)
-    last_sent_at = Column(DateTime, nullable=True)
-    created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+    printer_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
+    print_key: Mapped[str] = mapped_column(String(200), nullable=False)
+    job_key: Mapped[str | None] = mapped_column(String(160), nullable=True)
+    print_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
+    eta_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    eta_deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    credential_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    activity_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
+    state: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
+    end_reason: Mapped[str | None] = mapped_column(String(40), nullable=True)
+    failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    content: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+    expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)

+ 13 - 12
backend/app/models/notification_lock_screen_widget.py

@@ -2,7 +2,8 @@
 
 from datetime import datetime
 
-from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
 
@@ -11,17 +12,17 @@ class NotificationLockScreenWidget(Base):
     __tablename__ = "notification_lock_screen_widgets"
     __table_args__ = (UniqueConstraint("provider_id", "printer_id"),)
 
-    id = Column(Integer, primary_key=True)
-    provider_id = Column(
+    id: Mapped[int] = mapped_column(Integer, primary_key=True)
+    provider_id: Mapped[int] = mapped_column(
         Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False, index=True
     )
     # A removed printer must leave ownership intact until the remote DELETE succeeds.
-    printer_id = Column(Integer, nullable=False, index=True)
-    credential_key = Column(String(64), nullable=False)
-    widget_id = Column(String(16), nullable=True)
-    state = Column(String(20), nullable=False, default="pending")
-    content = Column(Text, nullable=False, default="{}")
-    failures = Column(Integer, nullable=False, default=0)
-    next_attempt_at = Column(DateTime, nullable=True)
-    last_sent_at = Column(DateTime, nullable=True)
-    created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+    printer_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
+    credential_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    widget_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
+    state: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
+    content: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+    failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)

+ 2 - 2
backend/app/models/notification_template.py

@@ -222,13 +222,13 @@ DEFAULT_TEMPLATES = [
         "event_type": "user_created",
         "name": "Welcome Email",
         "title_template": "Welcome to {app_name}",
-        "body_template": "Welcome {username}!\n\nYour account has been created.\nUsername: {username}\nPassword: {password}\n\nLogin at: {login_url}",
+        "body_template": "Welcome {username}!\n\nYour {app_name} account has been created. Please change your password after your first login.\n\nLogin at: {login_url}\n\nYour login details:",
     },
     {
         "event_type": "password_reset",
         "name": "Password Reset",
         "title_template": "{app_name} - Password Reset",
-        "body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
+        "body_template": "Hello {username},\n\nA password reset was requested for your {app_name} account. If you did not request it, you can ignore this email: your password has not been changed.\n\nSet a new password with the link below (valid for 1 hour, usable once):",
     },
     # Inventory stock alert templates
     {

+ 3 - 2
backend/app/models/oidc_provider.py

@@ -14,6 +14,7 @@ from sqlalchemy import (
     Text,
     UniqueConstraint,
     func,
+    true,
 )
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
@@ -90,14 +91,14 @@ class OIDCProvider(Base):
     # and is the recommended Azure configuration.
     # Has no interaction with require_email_verified when set to a non-"email" value:
     # custom claims never perform an email_verified check regardless of that setting.
-    email_claim: Mapped[str] = mapped_column(String(64), default="email")
+    email_claim: Mapped[str] = mapped_column(String(64), default="email", server_default="email")
     # When True (default), the "email" claim is only trusted when email_verified=True.
     # Set to False to accept the email even when email_verified is absent — required
     # for providers like Azure Entra ID that never send email_verified and where a
     # custom claim (email_claim != "email") is not preferred.
     # Has no effect when email_claim is not "email": the custom-claim path never
     # performs an email_verified check regardless of this setting.
-    require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
+    require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
     # Nullable FK — configurable default group for auto-created OIDC users.
     # Falls back to "Viewers" when None. ON DELETE SET NULL fires on PostgreSQL;
     # SQLite ignores it (no PRAGMA foreign_keys=ON), so runtime resolution handles dangling refs.

+ 9 - 9
backend/app/models/print_queue.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -105,19 +105,19 @@ class PrintQueueItem(Base):
 
     # Printer-card direct uploads create transient library rows. When this is
     # true, the scheduler deletes the source row/files after archiving a copy.
-    cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
+    cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
     # strings (off/on/auto) matching BambuStudio; "auto" = skip if recently done.
     # The remaining three stay boolean (BambuStudio exposes no auto for them).
-    bed_levelling: Mapped[str] = mapped_column(String(8), default="auto")
-    flow_cali: Mapped[str] = mapped_column(String(8), default="auto")
+    bed_levelling: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
+    flow_cali: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
     vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
     layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
     timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
-    nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto")
+    nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
     # Ask for a post-print outcome verdict when this job completes (#1898).
     # Copied onto the archive as confirm_requested at dispatch, like plate_id.
     confirm_outcome: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
@@ -128,7 +128,7 @@ class PrintQueueItem(Base):
     # tray type]) → 0 (skips chamber phase). 'inherit' + global off + override
     # null = no preheat. Default 'inherit' so existing queue items behave
     # exactly as before the migration.
-    preheat_override: Mapped[str] = mapped_column(String(10), default="inherit")
+    preheat_override: Mapped[str] = mapped_column(String(10), default="inherit", server_default="inherit")
     preheat_chamber_target_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
     # Status: pending, printing, completed, failed, skipped, cancelled
@@ -151,14 +151,14 @@ class PrintQueueItem(Base):
     # lookback excluded `skipped` but had no way to dismiss the originating
     # failure. The flag is per-item, not per-printer, so a fresh failure
     # after a resume re-gates downstream items independently.
-    gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
+    gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Set by the dispatch scheduler when the assigned spool can't satisfy
     # this print's per-slot filament weight (#1496). Display-only flag — the
     # actual deficit is recomputed live every time the user clicks ▶, so
     # swapping a spool to a fuller one between flag and dispatch clears the
     # block automatically.
-    filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
+    filament_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # User has acknowledged the filament-shortage warning for this item
     # ("Print Anyway"). Set by the start route when the user passes
@@ -166,7 +166,7 @@ class PrintQueueItem(Base):
     # frontend deficit warning was acknowledged. Survives scheduler ticks so
     # the dispatch no longer bounces between "user said anyway" and
     # "scheduler re-flagged" (#1698-followup).
-    skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False)
+    skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Tracking
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 11 - 4
backend/app/models/printer.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, String, func
+from sqlalchemy import Boolean, DateTime, Float, String, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -8,6 +8,8 @@ from backend.app.core.database import Base
 
 class Printer(Base):
     __tablename__ = "printers"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))
@@ -41,7 +43,7 @@ class Printer(Base):
     camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
     # Picked for the chamber light while the camera is in use (#1655). Only
     # counts when the app setting camera_light_mode is "selected".
-    camera_light_auto: Mapped[bool] = mapped_column(Boolean, default=False)
+    camera_light_auto: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     # Plate detection - check if build plate is empty before starting print
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     # ROI for plate detection (percentages: 0.0-1.0)
@@ -57,8 +59,13 @@ class Printer(Base):
 
     # Relationships
     archives: Mapped[list["PrintArchive"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
-    smart_plugs: Mapped[list["SmartPlug"]] = relationship(back_populates="printer")
-    notification_providers: Mapped[list["NotificationProvider"]] = relationship(back_populates="printer")
+    # Deleted with the printer, as providers: unassigned plugs are everyone's.
+    smart_plugs: Mapped[list["SmartPlug"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
+    # Deleted with the printer: setting their printer_id to NULL (the default)
+    # would make them providers for all printers.
+    notification_providers: Mapped[list["NotificationProvider"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
     )

+ 4 - 0
backend/app/models/project.py

@@ -5,6 +5,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
+# The priorities and statuses the project editor accepts.
+PROJECT_PRIORITIES = ("low", "normal", "high", "urgent")
+PROJECT_STATUSES = ("active", "completed", "archived")
+
 
 class Project(Base):
     """Project to group related prints (e.g., 'Voron Build' with multiple parts)."""

+ 4 - 2
backend/app/models/smart_plug.py

@@ -80,8 +80,10 @@ class SmartPlug(Base):
     rest_energy_total_path: Mapped[str | None] = mapped_column(String(200), nullable=True)
     rest_energy_total_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")
 
-    # Link to printer (multiple plugs/scripts can be linked to one printer)
-    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
+    # Link to printer (multiple plugs/scripts can be linked to one printer).
+    # CASCADE, not SET NULL: a plug without a printer is everyone's, so one left
+    # by a deleted printer would open the hardware it powers to every user.
+    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
 
     # Whether this plug actually feeds the printer's own power (#2629). The
     # printer link is also used for accessories that merely follow the print

+ 3 - 1
backend/app/models/spool.py

@@ -10,6 +10,8 @@ class Spool(Base):
     """Spool inventory item for tracking filament spools and their properties."""
 
     __tablename__ = "spool"
+    # A freed id must not pass to a new spool: its usage history would follow.
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     material: Mapped[str] = mapped_column(String(50))  # PLA, PETG, ABS, etc.
@@ -36,7 +38,7 @@ class Spool(Base):
     # usage to 0" action stamps baseline = weight_used so the counter zeroes
     # without disturbing remaining (= label_weight - weight_used). Matches
     # Spoolman's split between used_weight and remaining_weight (#1390).
-    weight_used_baseline: Mapped[float] = mapped_column(Float, default=0)
+    weight_used_baseline: Mapped[float] = mapped_column(Float, default=0, server_default="0")
     weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
     last_scale_weight: Mapped[int | None] = mapped_column(Integer)  # Last gross weight from scale (g)
     last_weighed_at: Mapped[datetime | None] = mapped_column(DateTime)  # When last weighed

+ 6 - 2
backend/app/models/spool_usage_history.py

@@ -12,10 +12,14 @@ class SpoolUsageHistory(Base):
     __tablename__ = "spool_usage_history"
 
     id: Mapped[int] = mapped_column(primary_key=True)
-    spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"))
+    # Kept when the spool is deleted, like the archive link below: the usage is
+    # what the prints cost, and what recalculating their cost reads.
+    spool_id: Mapped[int | None] = mapped_column(ForeignKey("spool.id", ondelete="SET NULL"), nullable=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"))
     print_name: Mapped[str | None] = mapped_column(String(500))
-    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id"), nullable=True)
+    # The spool's consumption outlives the archive: deleting the archive only
+    # drops the link.
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
     weight_used: Mapped[float] = mapped_column(Float, default=0)
     percent_used: Mapped[int] = mapped_column(Integer, default=0)
     status: Mapped[str] = mapped_column(String(20), default="completed")  # completed/failed/aborted

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio