Jelajahi Sumber

fix(printers): a printer added after a delete starts clean

SQLite gives a deleted printer's id to the next printer created, which
inherited what Bambuddy kept under it: the running print's owner (kill
switch, billing), pending prints, the plate-clear gate, notifications,
connect-time steps, camera streams, Obico and scheduler state, the
tracked print and the plate-detection references. Deleting a printer now
drops all of it, and a new printer starts without plate references.
maziggy 1 Minggu lalu
induk
melakukan
578add28d8

+ 0 - 16
.basedpyright/baseline.json

@@ -315,22 +315,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 24,
-                    "endColumn": 51,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 24,
-                    "endColumn": 52,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {

+ 6 - 0
CHANGELOG.md

@@ -162,6 +162,12 @@ 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
+- **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.

+ 54 - 12
backend/app/api/routes/camera.py

@@ -1044,21 +1044,36 @@ async def camera_stream(
             viewer was attached. Raw frame, not the multipart-wrapped chunk the
             generator yields, because that is what those consumers expect.
             """
-            _last_frames[printer_id] = frame
+            if not stop_event.is_set():
+                _last_frames[printer_id] = frame
+
+        camera_url = printer.external_camera_url
+        # None and "" take the same branch in generate_mjpeg_stream.
+        camera_type = printer.external_camera_type or ""
 
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             try:
                 # Chamber light while the viewer watches (#1655).
-                async with camera_light(printer, wait=False):
-                    async for frame in generate_mjpeg_stream(
-                        printer.external_camera_url,
-                        printer.external_camera_type,
-                        fps,
-                        on_process=_register_external_process,
-                        on_frame=_publish_external_frame,
-                        stop_event=stop_event,
-                    ):
+                async with (
+                    camera_light(printer, wait=False),
+                    contextlib.aclosing(
+                        generate_mjpeg_stream(
+                            camera_url,
+                            camera_type,
+                            fps,
+                            on_process=_register_external_process,
+                            on_frame=_publish_external_frame,
+                            stop_event=stop_event,
+                        )
+                    ) as frames,
+                ):
+                    async for frame in frames:
+                        # Set by forget_printer: an MJPEG or snapshot camera runs
+                        # no process a stop could kill, and only checks it
+                        # between sessions.
+                        if stop_event.is_set():
+                            break
                         # generate_mjpeg_stream already handles rate limiting;
                         # track frame times (per-printer + per-stream) for stall detection
                         now = time.time()
@@ -1221,6 +1236,34 @@ async def stop_camera_stream(
         )
         return {"stopped": 0, "skipped": True}
 
+    stopped = await _stop_printer_streams(printer_id)
+    logger.info("Stopped %s camera stream(s) for printer %s", stopped, printer_id)
+    return {"stopped": stopped}
+
+
+async def forget_printer(printer_id: int) -> None:
+    """End a deleted printer's streams and drop its frames.
+
+    Viewers still attached are cut off: the next printer given the id (SQLite
+    reuses it) would otherwise join the old printer's feed, and snapshots read
+    its last frame.
+    """
+    await _stop_printer_streams(printer_id)
+    # External MJPEG and snapshot streams run no process the stop finds; they end
+    # at their next frame once told to. Not in _stop_printer_streams: the stop
+    # route would end every other viewer's stream of the printer with them.
+    for stream_id, event in list(_disconnect_events.items()):
+        if stream_id.startswith(f"{printer_id}-"):
+            event.set()
+    _last_frames.pop(printer_id, None)
+    _last_frame_times.pop(printer_id, None)
+    _stream_start_times.pop(printer_id, None)
+    _active_external_streams.discard(printer_id)
+
+
+async def _stop_printer_streams(printer_id: int) -> int:
+    """Stop the fan-out broadcaster and the stream processes of a printer."""
+    broadcaster_key = f"printer-{printer_id}"
     stopped = 0
 
     # Tear down the fan-out broadcaster first (#1089). This cleanly notifies
@@ -1274,8 +1317,7 @@ async def stop_camera_stream(
         _disconnect_events.pop(stream_id, None)
         _stream_last_frame_times.pop(stream_id, None)
 
-    logger.info("Stopped %s camera stream(s) for printer %s", stopped, printer_id)
-    return {"stopped": stopped}
+    return stopped
 
 
 @router.get("/{printer_id}/camera/snapshot")

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

@@ -207,6 +207,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]):
@@ -578,6 +583,11 @@ async def delete_printer(
             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}
 

+ 5 - 1
backend/app/core/sqlite_fk.py

@@ -80,7 +80,11 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("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.
+        # 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"),
         # smart_plugs: a printer delete now deletes its plugs; their energy

+ 88 - 0
backend/app/main.py

@@ -1041,6 +1041,94 @@ def forget_print_creator(user_id: int) -> None:
         del _expected_print_creators[key]
 
 
+async def forget_printer(printer_id: int) -> None:
+    """Drop what is kept for a deleted printer's id, here and in the services.
+
+    SQLite gives the id to the next printer created, which inherited it all: the
+    running print's owner (its prints passed the kill switch and were billed to
+    that user), the plate-clear gate, pending and suppressed notifications, the
+    camera feed, the plate references. Called after the delete is committed.
+    Every printer-keyed map of this module is listed here or, with the reason,
+    in tests/unit/test_forget_printer.py.
+    """
+    from backend.app.api.routes import camera
+    from backend.app.services import (
+        bambu_ftp,
+        kprofile_drift,
+        layer_timelapse,
+        plate_detection,
+        print_dispatch_context,
+        usage_tracker,
+    )
+    from backend.app.services.notify_live_activities import notify_live_activities
+    from backend.app.services.obico_detection import obico_detection_service
+
+    # Keyed (printer_id, filename). The archive-keyed maps of those prints go too.
+    archive_ids = set()
+    for tracked in (_active_prints, _expected_prints):
+        for key in [key for key in tracked if key[0] == printer_id]:
+            archive_ids.add(tracked.pop(key))
+    for archive_id in archive_ids:
+        _print_ams_mappings.pop(archive_id, None)
+        _print_cost_center_ids.pop(archive_id, None)
+        _print_plate_ids.pop(archive_id, None)
+    for tracked in (_expected_print_creators, _expected_print_registered_at):
+        for key in [key for key in tracked if key[0] == printer_id]:
+            del tracked[key]
+    for key in [key for key in _ams_index_only_logged if key[0] == printer_id]:
+        _ams_index_only_logged.discard(key)
+    for key in [key for key in _ams_alarm_cooldown if key.split(":", 1)[0] == str(printer_id)]:
+        del _ams_alarm_cooldown[key]
+
+    # Pending work that would act on the id: an offline notice, a 3MF retry.
+    # A kill-switch notice is about a stop that happened and is left to finish.
+    for tasks in (_printer_offline_notify_tasks, _fallback_3mf_retry_tasks):
+        task = tasks.pop(printer_id, None)
+        if task is not None:
+            task.cancel()
+    for keyed in (
+        _stage22_finish_frames,
+        _stage22_finish_in_flight,
+        _inprint_frame_bank,
+        _inprint_frame_bank_ts,
+        _printer_reconciled_since_connect,
+        _printer_kprofiles_primed_since_connect,
+        _last_progress_milestone,
+        _first_layer_notified,
+        _kill_switch_notification_tasks,
+        _notified_hms_errors,
+        _hms_print_state,
+        _hms_forget_when_gone,
+        _timelapse_baselines,
+        _bed_cool_waiters,
+        _printer_last_connected,
+        _unknown_tag_last_broadcast,
+        _last_status_broadcast,
+        _connection_watchdog_last_attempt,
+    ):
+        keyed.pop(printer_id, None)
+    for marked in (
+        _unauthorized_print_kill_sent,
+        _user_stopped_printers,
+        _printer_offline_published,
+        _nozzle_count_updated,
+    ):
+        marked.discard(printer_id)
+
+    printer_manager.forget_printer(printer_id)
+    print_dispatch_context.clear(printer_id)
+    kprofile_drift.forget_printer(printer_id, deleted=True)
+    layer_timelapse.cancel_session(printer_id)
+    usage_tracker.forget_printer(printer_id)
+    obico_detection_service.forget_printer(printer_id)
+    bambu_ftp.clear_3mf_cache(printer_id)
+    await camera.forget_printer(printer_id)
+    printers.clear_cover_cache(printer_id)
+    print_scheduler.forget_printer(printer_id)
+    notify_live_activities.forget_printer(printer_id)
+    plate_detection.remove_printer_references(printer_id)
+
+
 def register_expected_print(
     printer_id: int,
     filename: str,

+ 7 - 2
backend/app/services/kprofile_drift.py

@@ -76,14 +76,19 @@ def note_slot_configured(printer_id: int, ams_id: int, tray_id: int, cali_idx: i
         _default_chosen.discard(key)
 
 
-def forget_printer(printer_id: int) -> None:
+def forget_printer(printer_id: int, *, deleted: bool = False) -> None:
     """Drop the retry bookkeeping for a printer, e.g. when it disconnects.
 
     A reconnect is a fresh start: a profile given up on before may stick now.
-    A deliberate default pick is kept -- it still describes the slot.
+    A deliberate default pick is kept -- it still describes the slot -- unless
+    the printer was deleted: then it describes nothing, and SQLite gives the id
+    to the next printer created.
     """
     for key in [k for k in _watches if k[0] == printer_id]:
         del _watches[key]
+    if deleted:
+        for key in [k for k in _default_chosen if k[0] == printer_id]:
+            _default_chosen.discard(key)
 
 
 def _selection_lost(tray: dict) -> bool:

+ 7 - 0
backend/app/services/notify_live_activities.py

@@ -246,6 +246,13 @@ class NotifyLiveActivityService(NotifyWorkerLifecycle):
         self._init_worker()
         self._status_grace_until: datetime | None = None
 
+    def forget_printer(self, printer_id: int) -> None:
+        """Drop a deleted printer's print, and end its tiles now: the next tick
+        ends them (the printer is gone), before a new printer is given the id."""
+        for state in (self._snapshots, self._new_fallbacks, self._started_at, self._progress_resets):
+            state.pop(printer_id, None)
+        self._wake.set()
+
     def _on_activated(self):
         # No MQTT observations are accepted while dormant. Anything retained
         # from before opt-out is stale until a real status arrives after opt-in.

+ 10 - 1
backend/app/services/obico_detection.py

@@ -110,6 +110,11 @@ class ObicoDetectionService:
         self._history: deque = deque(maxlen=HISTORY_MAX)
         self._last_error: str | None = None
 
+    def forget_printer(self, printer_id: int) -> None:
+        """A deleted printer's verdict and score, not the next printer's given its id."""
+        for state in (self._states, self._state_keys, self._last_class, self._errors, self._action_fired):
+            state.pop(printer_id, None)
+
     # ---- lifecycle ----
 
     async def start(self):
@@ -281,6 +286,8 @@ class ObicoDetectionService:
         "not checking" instead of inheriting the previous verdict — or, worse,
         the default "safe" a printer used to get before its first inference.
         """
+        if printer_id not in self._states:
+            return  # deleted while the check waited (forget_printer)
         self._errors[printer_id] = reason
         self._last_error = reason
         logger.warning(reason)
@@ -348,7 +355,9 @@ class ObicoDetectionService:
 
         detections = payload.get("detections", []) if isinstance(payload, dict) else []
         current_p = score_from_detections(detections)
-        state = self._states[printer_id]
+        state = self._states.get(printer_id)
+        if state is None:
+            return  # deleted while the check waited (forget_printer)
         score = state.update(current_p)
         verdict = classify(score, settings["sensitivity"])
         self._last_class[printer_id] = verdict

+ 31 - 0
backend/app/services/plate_detection.py

@@ -9,6 +9,7 @@ from __future__ import annotations
 
 import logging
 import os
+import re
 from pathlib import Path
 
 logger = logging.getLogger(__name__)
@@ -31,6 +32,36 @@ def _get_calibration_dir() -> Path:
     return settings.plate_calibration_dir
 
 
+_REFERENCE_FILE = re.compile(r"printer_(\d+)_(?:ref_\d+\.jpg|metadata\.json)")
+
+
+def _reference_files() -> list[tuple[int, Path]]:
+    directory = _get_calibration_dir()
+    if not directory.is_dir():
+        return []
+    return [
+        (int(m[1]), path) for path in directory.iterdir() if (m := _REFERENCE_FILE.fullmatch(path.name)) is not None
+    ]
+
+
+def _remove(path: Path) -> None:
+    try:
+        path.unlink()
+    except OSError as e:
+        logger.warning("Could not remove plate reference %s: %s", path, e)
+
+
+def remove_printer_references(printer_id: int) -> None:
+    """Remove a deleted printer's plate references (camera images of its bed).
+
+    Named by printer id, so the next printer given the id (SQLite reuses it)
+    would show them and compare its plate against them.
+    """
+    for owner, path in _reference_files():
+        if owner == printer_id:
+            _remove(path)
+
+
 class PlateDetectionResult:
     """Result of plate detection analysis."""
 

+ 24 - 0
backend/app/services/print_scheduler.py

@@ -1442,6 +1442,30 @@ class PrintScheduler:
         self._running = False
         logger.info("Print scheduler stopped")
 
+    def forget_printer(self, printer_id: int) -> None:
+        """Drop what is kept for a deleted printer, so the next printer given its
+        id (SQLite reuses it) starts clean: refused uploads and failed power-ons
+        would keep it out of dispatch for up to an hour.
+
+        A dispatch already in flight keeps its own state (_inflight, the preheat
+        pins, unconfirmed prints): it unwinds that when it ends.
+        """
+        for keyed in (
+            self._upload_backoff,
+            self._upload_refusals,
+            self._wake_failures,
+            self._drying_in_progress,
+            self._dispatch_holds,
+            self._chamber_history,
+            self._keep_warm,
+            self._terminal_since,
+        ):
+            keyed.pop(printer_id, None)
+        self._upload_refusal_notified.discard(printer_id)
+        for keyed in (self._auto_dry_units, self._auto_dry_above):
+            for key in [key for key in keyed if key[0] == printer_id]:
+                del keyed[key]
+
     async def check_queue(self) -> bool:
         """Check for prints ready to start.
 

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

@@ -440,6 +440,13 @@ class PrinterManager:
             if user.get("user_id") == user_id:
                 del self._current_print_user[printer_id]
 
+    def forget_printer(self, printer_id: int) -> None:
+        """A deleted printer's print owner, plate-clear gate and last trays go
+        with it, not to the next printer given its id (SQLite reuses it)."""
+        self._current_print_user.pop(printer_id, None)
+        self._awaiting_plate_clear.discard(printer_id)
+        self._last_trays.pop(printer_id, None)
+
     def is_awaiting_plate_clear(self, printer_id: int) -> bool:
         """Return True when the printer finished/failed a print and is waiting for the
         user to acknowledge the plate is cleared before the queue may dispatch the next job.

+ 6 - 0
backend/app/services/usage_tracker.py

@@ -419,6 +419,12 @@ async def clear_persisted_session(db: AsyncSession, printer_id: int) -> None:
         await db.commit()
 
 
+def forget_printer(printer_id: int) -> None:
+    """A deleted printer's print-start context in memory (the row goes with the printer)."""
+    _active_sessions.pop(printer_id, None)
+    _tray_change_locks.pop(printer_id, None)
+
+
 async def discard_session(db: AsyncSession, printer_id: int) -> None:
     """Forget a printer's print-start context, in memory and on disk.
 

+ 184 - 0
backend/tests/integration/test_printer_delete_leftovers.py

@@ -0,0 +1,184 @@
+"""Deleting a printer drops what Bambuddy kept for its id (SQLite gives the id
+to the next printer created). See unit/test_forget_printer.py for the full list."""
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app import main
+from backend.app.models.printer import Printer
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.printer_manager import printer_manager
+
+pytestmark = [pytest.mark.integration]
+
+
+async def test_the_next_printer_given_the_id_starts_clean(async_client: AsyncClient, db_session, tmp_path, monkeypatch):
+    from backend.app.services import plate_detection
+
+    monkeypatch.setattr(plate_detection, "_get_calibration_dir", lambda: tmp_path)
+    printer = Printer(name="A", serial_number="LEFT-A", ip_address="192.0.2.41", access_code="x")
+    db_session.add(printer)
+    await db_session.commit()
+    pid = printer.id
+    # Mid-print, dispatched for alice, plate calibrated.
+    printer_manager.set_current_print_user(pid, 1, "alice")
+    printer_manager._awaiting_plate_clear.add(pid)
+    main._active_prints[(pid, "cube.3mf")] = 42
+    (tmp_path / f"printer_{pid}_ref_0.jpg").write_bytes(b"jpg")
+    (tmp_path / f"printer_{pid}_metadata.json").write_text("{}")
+    try:
+        response = await async_client.delete(f"/api/v1/printers/{pid}")
+        assert response.status_code == 200, response.text
+
+        assert printer_manager.get_current_print_user(pid) is None
+        assert not printer_manager.is_awaiting_plate_clear(pid)
+        state = PrinterState(subtask_name="cube", gcode_file="cube.3mf")
+        assert not main._is_bambuddy_authorized_print_in_memory(pid, state)  # the kill switch stops it
+        assert list(tmp_path.iterdir()) == []
+    finally:
+        printer_manager._current_print_user.pop(pid, None)
+        printer_manager._awaiting_plate_clear.discard(pid)
+        main._active_prints.pop((pid, "cube.3mf"), None)
+
+
+async def _print_sessions(db, printer_id: int) -> None:
+    from datetime import datetime
+
+    from backend.app.models.active_print_session import ActivePrintSession
+    from backend.app.models.active_print_spoolman import ActivePrintSpoolman
+    from backend.app.models.archive import PrintArchive
+
+    archive = PrintArchive(filename="cube.3mf", file_path="x", file_size=1, printer_id=printer_id)
+    db.add(archive)
+    await db.commit()
+    db.add_all(
+        [
+            ActivePrintSession(printer_id=printer_id, print_name="cube", started_at=datetime(2026, 1, 1)),
+            ActivePrintSpoolman(printer_id=printer_id, archive_id=archive.id, ams_trays={}),
+        ]
+    )
+    await db.commit()
+
+
+async def _session_rows(db) -> tuple[list, list]:
+    from sqlalchemy import select
+
+    from backend.app.models.active_print_session import ActivePrintSession
+    from backend.app.models.active_print_spoolman import ActivePrintSpoolman
+
+    sessions = (await db.execute(select(ActivePrintSession.printer_id))).scalars().all()
+    spoolman = (await db.execute(select(ActivePrintSpoolman.printer_id))).scalars().all()
+    return list(sessions), list(spoolman)
+
+
+async def test_the_print_being_tracked_goes_with_the_printer(async_client: AsyncClient, db_session):
+    """A new printer given the id and already printing would restore it, and its
+    filament would be charged to the deleted printer's spools."""
+    printer = Printer(name="A", serial_number="LEFT-B", ip_address="192.0.2.42", access_code="x")
+    db_session.add(printer)
+    await db_session.commit()
+    await _print_sessions(db_session, printer.id)
+
+    response = await async_client.delete(f"/api/v1/printers/{printer.id}", params={"delete_archives": "false"})
+
+    assert response.status_code == 200, response.text
+    assert await _session_rows(db_session) == ([], [])
+
+
+async def test_the_startup_repair_drops_print_tracking_of_deleted_printers(db_session):
+    from sqlalchemy import text
+
+    from backend.app.core.database import _repair_sqlite_orphans
+
+    printer = Printer(name="A", serial_number="LEFT-C", ip_address="192.0.2.43", access_code="x")
+    db_session.add(printer)
+    await db_session.commit()
+    await _print_sessions(db_session, printer.id)
+    await db_session.execute(text("DELETE FROM printers WHERE id = :id"), {"id": printer.id})  # the old way
+    await db_session.commit()
+
+    await _repair_sqlite_orphans(await db_session.connection())
+    await db_session.commit()
+
+    assert await _session_rows(db_session) == ([], [])
+
+
+async def test_an_external_camera_stream_of_the_printer_ends(async_client: AsyncClient, db_session):  # noqa: ARG001 - patches the session factory
+    """An MJPEG or snapshot camera runs no ffmpeg the stop could find: a viewer
+    left open kept writing the old camera's frames under the id, which plate
+    detection and the finish photo of the next printer given it would read."""
+    import asyncio
+    from unittest.mock import MagicMock, patch
+
+    from backend.app.api.routes import camera
+
+    printer = Printer(
+        name="A",
+        serial_number="LEFT-D",
+        ip_address="192.0.2.44",
+        access_code="x",
+        external_camera_enabled=True,
+        external_camera_url="http://192.0.2.51/mjpeg",
+        external_camera_type="mjpeg",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+
+    async def _frames(*_args, on_frame=None, **_kwargs):
+        while True:
+            on_frame(b"\xff\xd8old\xff\xd9")
+            yield b"--frame\r\n"
+            await asyncio.sleep(0)
+
+    with patch("backend.app.services.external_camera.generate_mjpeg_stream", _frames):
+        response = await camera.camera_stream(printer.id, MagicMock(), fps=5, _=None)
+        stream = response.body_iterator
+        await anext(stream)
+        assert camera._last_frames.get(printer.id)
+
+        await camera.forget_printer(printer.id)
+
+        with pytest.raises(StopAsyncIteration):
+            await asyncio.wait_for(anext(stream), 2)
+    assert printer.id not in camera._last_frames
+
+
+async def test_a_new_printer_starts_without_plate_references(
+    async_client: AsyncClient, db_session, tmp_path, monkeypatch
+):
+    """Ones left under its id by a delete before deletes removed them would show
+    as its calibration and be compared with its plate."""
+    from unittest.mock import AsyncMock, patch
+
+    from backend.app.services import plate_detection
+
+    monkeypatch.setattr(plate_detection, "_get_calibration_dir", lambda: tmp_path)
+    existing = Printer(name="A", serial_number="LEFT-E", ip_address="192.0.2.45", access_code="x")
+    db_session.add(existing)
+    await db_session.commit()
+    reused = existing.id + 1  # SQLite: the highest id plus one
+    for printer_id in (existing.id, reused):
+        (tmp_path / f"printer_{printer_id}_ref_0.jpg").write_bytes(b"jpg")
+        (tmp_path / f"printer_{printer_id}_metadata.json").write_text("{}")
+
+    with patch(
+        "backend.app.api.routes.printers.printer_manager.test_connection",
+        AsyncMock(return_value={"success": True}),
+    ):
+        response = await async_client.post(
+            "/api/v1/printers/",
+            json={
+                "name": "B",
+                "serial_number": "LEFT-F",
+                "ip_address": "192.0.2.46",
+                "access_code": "12345678",
+                "is_active": False,
+            },
+        )
+
+    assert response.status_code == 200, response.text
+    assert response.json()["id"] == reused
+    assert sorted(p.name for p in tmp_path.iterdir()) == [
+        f"printer_{existing.id}_metadata.json",
+        f"printer_{existing.id}_ref_0.jpg",
+    ]

+ 275 - 0
backend/tests/unit/test_forget_printer.py

@@ -0,0 +1,275 @@
+"""What Bambuddy keeps per printer id goes when the printer is deleted.
+
+SQLite gives a deleted printer's id to the next printer created, which inherited
+it all: the old print's owner (its prints passed the kill switch and were billed
+to that user), the plate-clear gate, pending notifications, the old camera feed,
+the old printer's plate-detection references.
+"""
+
+import ast
+import asyncio
+from pathlib import Path
+
+import pytest
+
+from backend.app import main
+
+pytestmark = pytest.mark.asyncio
+
+GONE, KEPT = 5, 6
+
+# Module-level maps in main.py that are not keyed by a printer, or are kept on
+# purpose. Anything else main.py adds must be forgotten with the printer.
+NOT_FORGOTTEN = {
+    "_print_ams_mappings": "keyed by archive (dropped with the printer's expected/active prints)",
+    "_print_cost_center_ids": "keyed by archive (dropped with the printer's expected/active prints)",
+    "_print_plate_ids": "keyed by archive (dropped with the printer's expected/active prints)",
+    "_ams_assignment_locks": "a lock carries nothing over; popping one a task holds would let a second be made",
+    "_fallback_recovery_locks": "a lock carries nothing over (see its comment on popping)",
+    "_HMS_FAILURE_REASONS": "constant",
+    "_ACTIVE_PRINT_STATES": "constant",
+    "_SENSOR_TARGET_KEYS": "constant",
+    "PUBLIC_API_ROUTES": "constant",
+    "_HTML_CACHE_HEADERS": "constant",
+}
+
+
+def _module_maps() -> set[str]:
+    """Names of main.py's module-level dicts and sets."""
+    tree = ast.parse(Path(main.__file__).read_text())
+    names = set()
+    for node in tree.body:
+        if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
+            kind = ast.unparse(node.annotation)
+            if kind.startswith(("dict", "set")):
+                names.add(node.target.id)
+        elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
+            if isinstance(node.value, (ast.Dict, ast.Set)):
+                names.add(node.targets[0].id)
+    return names
+
+
+def _sleeper() -> asyncio.Task:
+    return asyncio.ensure_future(asyncio.sleep(3600))
+
+
+def _entries(printer_id: int) -> dict[str, tuple]:
+    """For each forgotten map: (key, value) for this printer."""
+    return {
+        "_active_prints": ((printer_id, "cube.3mf"), 100 + printer_id),
+        "_expected_prints": ((printer_id, "cube.3mf"), 200 + printer_id),
+        "_expected_print_creators": ((printer_id, "cube.3mf"), 1),
+        "_expected_print_registered_at": ((printer_id, "cube.3mf"), 1.0),
+        "_stage22_finish_frames": (printer_id, b"jpg"),
+        "_stage22_finish_in_flight": (printer_id, asyncio.Event()),
+        "_inprint_frame_bank": (printer_id, b"jpg"),
+        "_inprint_frame_bank_ts": (printer_id, 1.0),
+        "_printer_reconciled_since_connect": (printer_id, True),
+        "_printer_kprofiles_primed_since_connect": (printer_id, True),
+        "_last_progress_milestone": (printer_id, 50),
+        "_first_layer_notified": (printer_id, True),
+        "_unauthorized_print_kill_sent": (printer_id, None),
+        "_kill_switch_notification_tasks": (printer_id, _sleeper()),
+        "_notified_hms_errors": (printer_id, {"0300_0001": 1.0}),
+        "_hms_print_state": (printer_id, "RUNNING"),
+        "_hms_forget_when_gone": (printer_id, {"0300_0001"}),
+        "_timelapse_baselines": (printer_id, {"video.mp4"}),
+        "_bed_cool_waiters": (printer_id, {"threshold": 35.0, "filename": "cube", "registered_at": 1.0}),
+        "_user_stopped_printers": (printer_id, None),
+        "_printer_last_connected": (printer_id, True),
+        "_printer_offline_published": (printer_id, None),
+        "_printer_offline_notify_tasks": (printer_id, _sleeper()),
+        "_unknown_tag_last_broadcast": (printer_id, {(0, 0): ("uid", "x")}),
+        "_last_status_broadcast": (printer_id, "hash"),
+        "_nozzle_count_updated": (printer_id, None),
+        "_fallback_3mf_retry_tasks": (printer_id, _sleeper()),
+        "_connection_watchdog_last_attempt": (printer_id, 1.0),
+        "_ams_index_only_logged": ((printer_id, 0), None),
+        "_ams_alarm_cooldown": (f"{printer_id}:0:humidity", None),
+    }
+
+
+def _put(name: str, key, value) -> None:
+    target = getattr(main, name)
+    if isinstance(target, set):
+        target.add(key)
+    else:
+        target[key] = value
+
+
+def _has(name: str, key) -> bool:
+    return key in getattr(main, name)
+
+
+async def test_every_printer_keyed_map_in_main_is_forgotten():
+    covered = set(_entries(GONE))
+    unclassified = _module_maps() - covered - set(NOT_FORGOTTEN)
+    assert not unclassified, (
+        f"main.py maps neither forgotten with a deleted printer nor listed in NOT_FORGOTTEN: {sorted(unclassified)}"
+    )
+
+
+async def test_forget_printer_clears_main_and_keeps_other_printers():
+    gone, kept = _entries(GONE), _entries(KEPT)
+    for name in gone:
+        _put(name, *gone[name])
+        _put(name, *kept[name])
+    main._print_ams_mappings[100 + GONE] = [0]
+    main._print_cost_center_ids[200 + GONE] = 1
+    main._print_plate_ids[200 + GONE] = 2
+    pending = {name: gone[name][1] for name in ("_printer_offline_notify_tasks", "_fallback_3mf_retry_tasks")}
+    try:
+        await main.forget_printer(GONE)
+
+        left = [name for name in gone if _has(name, gone[name][0])]
+        assert left == []
+        lost = [name for name in kept if not _has(name, kept[name][0])]
+        assert lost == []
+        assert 100 + GONE not in main._print_ams_mappings
+        assert 200 + GONE not in main._print_cost_center_ids
+        assert 200 + GONE not in main._print_plate_ids
+        await asyncio.sleep(0)
+        assert all(task.cancelled() for task in pending.values())
+    finally:
+        for entries in (gone, kept):
+            for name, (key, value) in entries.items():
+                target = getattr(main, name)
+                if isinstance(target, set):
+                    target.discard(key)
+                else:
+                    target.pop(key, None)
+                if isinstance(value, asyncio.Task):
+                    value.cancel()
+
+
+async def test_forget_printer_clears_the_services():
+    from backend.app.api.routes import camera
+    from backend.app.services import (
+        bambu_ftp,
+        kprofile_drift,
+        layer_timelapse,
+        obico_detection,
+        print_dispatch_context,
+        usage_tracker,
+    )
+    from backend.app.services.printer_manager import printer_manager
+
+    printer_manager._current_print_user[GONE] = {"user_id": 1, "username": "alice"}
+    printer_manager._awaiting_plate_clear.add(GONE)
+    printer_manager._last_trays[GONE] = {"ams": []}
+    print_dispatch_context.mark_pending(GONE)
+    kprofile_drift.note_slot_configured(GONE, 0, 0, -1)
+    kprofile_drift._watches[(GONE, 0, 1)] = kprofile_drift._SlotWatch()
+    layer_timelapse._active_sessions[GONE] = type("S", (), {"cleanup": lambda self: None})()
+    usage_tracker._active_sessions[GONE] = object()
+    service = obico_detection.obico_detection_service
+    for attr in ("_states", "_state_keys", "_last_class", "_errors", "_action_fired"):
+        getattr(service, attr)[GONE] = object()
+    bambu_ftp._threemf_path_cache[(GONE, "cube")] = Path("/nonexistent/cube.3mf")
+    camera._last_frames[GONE] = b"jpg"
+    camera._last_frame_times[GONE] = 1.0
+    camera._stream_start_times[GONE] = 1.0
+    camera._active_external_streams.add(GONE)
+    from backend.app.api.routes import printers
+
+    printers._cover_cache[GONE] = {("Cube", "front"): b"png"}
+    printers._cover_404_cache[GONE] = {}
+
+    await main.forget_printer(GONE)
+
+    assert GONE not in printer_manager._current_print_user
+    assert GONE not in printer_manager._awaiting_plate_clear
+    assert GONE not in printer_manager._last_trays
+    assert not print_dispatch_context.adopt(GONE)
+    assert (GONE, 0, 0) not in kprofile_drift._default_chosen
+    assert (GONE, 0, 1) not in kprofile_drift._watches
+    assert GONE not in layer_timelapse._active_sessions
+    assert GONE not in usage_tracker._active_sessions
+    for attr in ("_states", "_state_keys", "_last_class", "_errors", "_action_fired"):
+        assert GONE not in getattr(service, attr), attr
+    assert (GONE, "cube") not in bambu_ftp._threemf_path_cache
+    assert GONE not in camera._last_frames
+    assert GONE not in camera._last_frame_times
+    assert GONE not in camera._stream_start_times
+    assert GONE not in camera._active_external_streams
+    assert GONE not in printers._cover_cache
+    assert GONE not in printers._cover_404_cache
+
+
+async def test_the_plate_references_of_a_printer_go(tmp_path, monkeypatch):
+    from backend.app.services import plate_detection
+
+    monkeypatch.setattr(plate_detection, "_get_calibration_dir", lambda: tmp_path)
+    for printer_id in (GONE, KEPT, 55):
+        (tmp_path / f"printer_{printer_id}_ref_0.jpg").write_bytes(b"jpg")
+        (tmp_path / f"printer_{printer_id}_metadata.json").write_text("{}")
+
+    plate_detection.remove_printer_references(GONE)
+    assert sorted(p.name for p in tmp_path.iterdir()) == [
+        "printer_55_metadata.json",
+        "printer_55_ref_0.jpg",
+        f"printer_{KEPT}_metadata.json",
+        f"printer_{KEPT}_ref_0.jpg",
+    ]
+
+
+async def test_the_scheduler_and_live_activities_forget_it():
+    from backend.app.services.notify_live_activities import notify_live_activities
+    from backend.app.services.print_scheduler import scheduler
+
+    scheduler._upload_backoff[GONE] = 1e12
+    scheduler._upload_refusals[GONE] = 6
+    scheduler._upload_refusal_notified.add(GONE)
+    scheduler._wake_failures[GONE] = 1e12
+    scheduler._dispatch_holds[GONE] = (1.0, "IDLE", None)
+    scheduler._auto_dry_units[(GONE, 0)] = {}
+    for attr in ("_snapshots", "_new_fallbacks", "_started_at", "_progress_resets"):
+        getattr(notify_live_activities, attr)[GONE] = object()
+    notify_live_activities._wake.clear()
+
+    await main.forget_printer(GONE)
+
+    assert GONE not in scheduler._upload_backoff
+    assert GONE not in scheduler._upload_refusals
+    assert GONE not in scheduler._upload_refusal_notified
+    assert GONE not in scheduler._wake_failures
+    assert GONE not in scheduler._dispatch_holds
+    assert (GONE, 0) not in scheduler._auto_dry_units
+    for attr in ("_snapshots", "_new_fallbacks", "_started_at", "_progress_resets"):
+        assert GONE not in getattr(notify_live_activities, attr), attr
+    # Its tiles are ended at once (the printer is gone), before a new one takes the id.
+    assert notify_live_activities._wake.is_set()
+
+
+@pytest.mark.parametrize("ml_fails", [False, True])
+async def test_an_obico_check_in_flight_when_the_printer_goes_ends_quietly(ml_fails):
+    """The check waits up to 30 s for the ML server. A KeyError there stopped the
+    poll for every printer; an error written back stuck to the next printer given the id."""
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    from httpx import Request, Response
+
+    from backend.app.services.obico_detection import ObicoDetectionService
+
+    service = ObicoDetectionService()
+
+    async def ml_call(url, **_kwargs):
+        service.forget_printer(GONE)  # deleted meanwhile
+        if ml_fails:
+            raise OSError("ML server gone")
+        return Response(200, json={"detections": []}, request=Request("GET", url))
+
+    client = MagicMock()
+    client.get = AsyncMock(side_effect=ml_call)
+    client.__aenter__ = AsyncMock(return_value=client)
+    client.__aexit__ = AsyncMock(return_value=False)
+    settings = {"sensitivity": "medium", "snapshot_base_url": "http://b", "ml_url": "http://o", "action": "notify"}
+    with (
+        patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=client),
+        patch.object(service, "_capture_frame", new=AsyncMock(return_value=b"\xff\xd8jpg\xff\xd9")),
+    ):
+        await service._check_printer(GONE, MagicMock(state="RUNNING", task_name="job", subtask_name=""), settings)
+
+    for attr in ("_states", "_state_keys", "_last_class", "_errors", "_action_fired"):
+        assert GONE not in getattr(service, attr), attr
+    assert service._last_error is None