فهرست منبع

Obico: score like Obico, keep a print's state through a pause

- The score skipped Obico's baseline step: max(ewm, short - long)
  added whatever the camera always sees (plate texture, labels) to
  it, and the action ran as soon as it passed High (#3459).
- obico_smoothing is now Obico's is_failing(): ewm minus the long
  mean against LOW/HIGH, the x3.8 short-window rule in between, and
  the 1.75 escalation. "warning" is Obico's alert, "failure" its
  pause; Notify fires at warning, Pause / Pause and cut power at
  failure only. Sensitivity still scales the thresholds, which is
  equivalent.
- The long mean is kept per printer in memory, across its prints.
- A pause no longer resets detection: state ends on FINISH, FAILED,
  IDLE, PREPARE or SLICING, a new task name or another job id, so a
  print Obico paused isn't paused again after resume.
- Status panel shows the pause level (all locales); shown scores are
  not below 0.
- Tests for the math, each lifecycle case and each action; CHANGELOG.
maziggy 2 روز پیش
والد
کامیت
26af9dfc3f

+ 1 - 0
CHANGELOG.md

@@ -219,6 +219,7 @@ 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.
 - **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
 ### Fixed
+- **Obico failure detection scores like Obico, and a resumed print isn't paused again (#3459, reported by @jasoncronje)** — Bambuddy's port of Obico's scoring left out the step that subtracts the long-term average, so whatever the camera always sees (plate texture, labels) added straight into the score, and the configured action ran as soon as that score passed High. The decision is now Obico's own: the score is the smoothed value minus the printer's long-term average, which carries over between prints on the same printer, so a background that is always in view no longer builds up. Between Low and High a slow build-up is not failing. **Notify only** fires at Obico's alert level, while **Pause print** and **Pause and cut power** wait for its pause level, which takes a clearly stronger signal and is now shown in the Status panel. A detection that appears suddenly and stays is still treated as a failure, as in Obico. Pausing a print no longer resets detection, so a print Bambuddy paused is not paused again after you resume it; the next print starts fresh. Wiki updated.
 - **A dropped printer connection no longer replays old commands and cancels the print (#3454, reported and diagnosed by @SaadShaya7)** — When the connection to a printer dropped and came back by itself, every command still waiting for the printer's acknowledgement was sent again. Bambu printers often don't acknowledge, so that could be a Stop or a print start from hours earlier: a print 4 hours in was cancelled seconds after the reconnect, and an old print start raised "Device is busy and cannot start new task". Commands from the previous connection are now dropped when it comes back, also when a printer that was switched off is turned on again, and the log says how many. A command sent in the seconds before a drop isn't sent again; click it once more.
 - **A dropped printer connection no longer replays old commands and cancels the print (#3454, reported and diagnosed by @SaadShaya7)** — When the connection to a printer dropped and came back by itself, every command still waiting for the printer's acknowledgement was sent again. Bambu printers often don't acknowledge, so that could be a Stop or a print start from hours earlier: a print 4 hours in was cancelled seconds after the reconnect, and an old print start raised "Device is busy and cannot start new task". Commands from the previous connection are now dropped when it comes back, also when a printer that was switched off is turned on again, and the log says how many. A command sent in the seconds before a drop isn't sent again; click it once more.
 - **Finish photo: the plate is no longer driven to the bottom after a tall print** — With **Restore plate for finish photo** on, Bambuddy lowered the plate after the photo to the print's height + 100 mm, whatever the printer. On a 238 mm print on an H2C that is Z338, past the bottom of its travel: Bambu's own end G-code stops at the print's height + 2 mm when +100 doesn't fit, and the printer does not stop G-code sent over the network at its limits. The plate now goes back exactly where the print's end G-code left it, read from the archived file, and is only raised for the photo when the end G-code left it lower than just above the print (the H2C's doesn't, so its plate stays put). A file whose end position can't be read is not moved at all. Wiki updated.
 - **Finish photo: the plate is no longer driven to the bottom after a tall print** — With **Restore plate for finish photo** on, Bambuddy lowered the plate after the photo to the print's height + 100 mm, whatever the printer. On a 238 mm print on an H2C that is Z338, past the bottom of its travel: Bambu's own end G-code stops at the print's height + 2 mm when +100 doesn't fit, and the printer does not stop G-code sent over the network at its limits. The plate now goes back exactly where the print's end G-code left it, read from the archived file, and is only raised for the photo when the end G-code left it lower than just above the print (the H2C's doesn't, so its plate stays put). A file whose end position can't be read is not moved at all. Wiki updated.
 - **An archive card counts every print's video** — An archive printed more than once (each plate of a project, or a reprint) counted every print's finish photos on its card, but its video button knew only the latest print's video. The button now shows how many videos the prints have and, when there is more than one, opens the archive, where each print's video plays. A single video still plays in place. The list view and the card's menu do the same.
 - **An archive card counts every print's video** — An archive printed more than once (each plate of a project, or a reprint) counted every print's finish photos on its card, but its video button knew only the latest print's video. The button now shows how many videos the prints have and, when there is more than one, opens the archive, where each print's video plays. A single video still plays in place. The list view and the card's menu do the same.

+ 74 - 17
backend/app/services/obico_detection.py

@@ -4,7 +4,7 @@ Polls a self-hosted Obico ML API with snapshots from each monitored printer
 while a print is running, smooths scores over time, and dispatches a configured
 while a print is running, smooths scores over time, and dispatches a configured
 action (notify / pause / pause_and_off) when a sustained failure is detected.
 action (notify / pause / pause_and_off) when a sustained failure is detected.
 
 
-See `obico_smoothing.py` for the per-print EWM + rolling-mean math.
+See `obico_smoothing.py` for the EWM + rolling-mean math (Obico's own).
 """
 """
 
 
 import asyncio
 import asyncio
@@ -21,14 +21,24 @@ from sqlalchemy import select
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.services.obico_smoothing import (
 from backend.app.services.obico_smoothing import (
+    ESCALATING_FACTOR,
+    Baseline,
     PrintState,
     PrintState,
     classify,
     classify,
     score_from_detections,
     score_from_detections,
     thresholds,
     thresholds,
+    triggers_action,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# A print in these states is over, or a new one is being prepared: the next
+# RUNNING one starts fresh. PAUSE (and any state not listed) keeps the print's
+# smoothing and its "action fired", so a resumed print isn't re-armed (#3459).
+# PREPARE/SLICING only come before a print, and catch a reprint of the same
+# file with no job id whose FINISH fell between two polls.
+_ENDED_STATES = ("FINISH", "FAILED", "IDLE", "PREPARE", "SLICING")
+
 HISTORY_MAX = 50
 HISTORY_MAX = 50
 HEALTH_TIMEOUT = 5.0
 HEALTH_TIMEOUT = 5.0
 DETECTION_TIMEOUT = 30.0
 DETECTION_TIMEOUT = 30.0
@@ -95,6 +105,11 @@ class ObicoDetectionService:
         self._states: dict[int, PrintState] = {}
         self._states: dict[int, PrintState] = {}
         # printer_id -> task_name active when state was created (used to detect new prints)
         # printer_id -> task_name active when state was created (used to detect new prints)
         self._state_keys: dict[int, str] = {}
         self._state_keys: dict[int, str] = {}
+        # printer_id -> the printer's job id (subtask_id) for that state, once known
+        self._print_ids: dict[int, str] = {}
+        # printer_id -> long-term mean of its frames, kept across its prints so
+        # what its camera always sees counts as baseline (#3459). Memory only.
+        self._baselines: dict[int, Baseline] = {}
         # printer_id -> last classification ("safe"/"warning"/"failure").
         # printer_id -> last classification ("safe"/"warning"/"failure").
         # Only written after an inference actually came back, so a missing entry
         # Only written after an inference actually came back, so a missing entry
         # means "we have no verdict", which is not the same as "safe" (#2952).
         # means "we have no verdict", which is not the same as "safe" (#2952).
@@ -111,7 +126,15 @@ class ObicoDetectionService:
 
 
     def forget_printer(self, printer_id: int) -> None:
     def forget_printer(self, printer_id: int) -> None:
         """A deleted printer's verdict and score, not the next printer's given its id."""
         """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):
+        for state in (
+            self._states,
+            self._state_keys,
+            self._print_ids,
+            self._baselines,
+            self._last_class,
+            self._errors,
+            self._action_fired,
+        ):
             state.pop(printer_id, None)
             state.pop(printer_id, None)
 
 
     # ---- lifecycle ----
     # ---- lifecycle ----
@@ -134,6 +157,7 @@ class ObicoDetectionService:
         self.stop()
         self.stop()
         self._states.clear()
         self._states.clear()
         self._state_keys.clear()
         self._state_keys.clear()
+        self._print_ids.clear()
         self._action_fired.clear()
         self._action_fired.clear()
         self._last_class.clear()
         self._last_class.clear()
         self._errors.clear()
         self._errors.clear()
@@ -222,17 +246,42 @@ class ObicoDetectionService:
                 continue
                 continue
             if not printer_manager.is_connected(printer_id):
             if not printer_manager.is_connected(printer_id):
                 continue
                 continue
-            if not status or getattr(status, "state", None) != "RUNNING":
-                # Reset state when not printing so the next print starts fresh
-                self._states.pop(printer_id, None)
-                self._state_keys.pop(printer_id, None)
-                self._action_fired.pop(printer_id, None)
-                self._last_class.pop(printer_id, None)
-                self._errors.pop(printer_id, None)
+            print_state = getattr(status, "state", None) if status else None
+            if print_state != "RUNNING":
+                if print_state is None or print_state in _ENDED_STATES:
+                    # The print is over: the next one starts fresh
+                    self._end_print(printer_id)
                 continue
                 continue
 
 
             await self._check_printer(printer_id, status, settings)
             await self._check_printer(printer_id, status, settings)
 
 
+    def _end_print(self, printer_id: int) -> None:
+        for state in (
+            self._states,
+            self._state_keys,
+            self._print_ids,
+            self._action_fired,
+            self._last_class,
+            self._errors,
+        ):
+            state.pop(printer_id, None)
+
+    @staticmethod
+    def _job_id(status) -> str | None:
+        subtask_id = getattr(status, "subtask_id", None)
+        if isinstance(subtask_id, (str, int)) and str(subtask_id) not in ("", "0"):
+            return str(subtask_id)
+        return None
+
+    def _is_new_print(self, printer_id: int, key: str, job_id: str | None) -> bool:
+        """Another task name, or another job id once both are known: a
+        printer that went from one print to the next while we didn't see it
+        idle (a pause no longer resets) must not carry the first one's state."""
+        if self._state_keys.get(printer_id) != key:
+            return True
+        known = self._print_ids.get(printer_id)
+        return bool(known and job_id and known != job_id)
+
     async def _capture_frame(self, printer_id: int) -> bytes | None:
     async def _capture_frame(self, printer_id: int) -> bytes | None:
         """Capture one JPEG frame from the printer camera. Returns None on failure."""
         """Capture one JPEG frame from the printer camera. Returns None on failure."""
         from backend.app.services.failure_detection_camera import capture_detection_frame
         from backend.app.services.failure_detection_camera import capture_detection_frame
@@ -261,10 +310,15 @@ class ObicoDetectionService:
     async def _check_printer(self, printer_id: int, status, settings: dict):
     async def _check_printer(self, printer_id: int, status, settings: dict):
         task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
         task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
         key = f"{task_name}"
         key = f"{task_name}"
-        if self._state_keys.get(printer_id) != key:
-            self._states[printer_id] = PrintState()
+        job_id = self._job_id(status)
+        if self._is_new_print(printer_id, key, job_id):
+            baseline = self._baselines.setdefault(printer_id, Baseline())
+            self._states[printer_id] = PrintState(baseline=baseline)
             self._state_keys[printer_id] = key
             self._state_keys[printer_id] = key
+            self._print_ids.pop(printer_id, None)
             self._action_fired[printer_id] = False
             self._action_fired[printer_id] = False
+        if job_id:
+            self._print_ids[printer_id] = job_id
 
 
         # Capture locally first, then hand Obico a nonce URL that returns the
         # Capture locally first, then hand Obico a nonce URL that returns the
         # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a
         # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a
@@ -324,8 +378,9 @@ class ObicoDetectionService:
         state = self._states.get(printer_id)
         state = self._states.get(printer_id)
         if state is None:
         if state is None:
             return  # deleted while the check waited (forget_printer)
             return  # deleted while the check waited (forget_printer)
-        score = state.update(current_p)
-        verdict = classify(score, settings["sensitivity"])
+        reading = state.update(current_p)
+        score = reading.score
+        verdict = classify(reading, settings["sensitivity"])
         self._last_class[printer_id] = verdict
         self._last_class[printer_id] = verdict
         # A successful capture + ML call clears any transient error from previous
         # A successful capture + ML call clears any transient error from previous
         # polls (typical case: cold-start RTSP timeout on first frame after startup,
         # polls (typical case: cold-start RTSP timeout on first frame after startup,
@@ -341,13 +396,13 @@ class ObicoDetectionService:
                     "task_name": task_name,
                     "task_name": task_name,
                     "timestamp": datetime.now(timezone.utc).isoformat(),
                     "timestamp": datetime.now(timezone.utc).isoformat(),
                     "current_p": round(current_p, 4),
                     "current_p": round(current_p, 4),
-                    "score": round(score, 4),
+                    "score": round(max(0.0, score), 4),
                     "class": verdict,
                     "class": verdict,
                     "detections": len(detections),
                     "detections": len(detections),
                 }
                 }
             )
             )
 
 
-        if verdict == "failure" and not self._action_fired.get(printer_id):
+        if triggers_action(verdict, settings["action"]) and not self._action_fired.get(printer_id):
             self._action_fired[printer_id] = True
             self._action_fired[printer_id] = True
             await self._dispatch_action(printer_id, settings["action"], task_name, score, frame)
             await self._dispatch_action(printer_id, settings["action"], task_name, score, frame)
 
 
@@ -407,7 +462,8 @@ class ObicoDetectionService:
             result[pid] = {
             result[pid] = {
                 "class": verdict,
                 "class": verdict,
                 "frame_count": state.frame_count,
                 "frame_count": state.frame_count,
-                "score": round(state.ewm_mean, 4),
+                # Below the printer's background is no detection at all.
+                "score": round(max(0.0, state.last.score), 4),
                 "error": error,
                 "error": error,
             }
             }
         return result
         return result
@@ -422,7 +478,8 @@ class ObicoDetectionService:
             "is_running": self._task is not None and not self._task.done(),
             "is_running": self._task is not None and not self._task.done(),
             "last_error": self._last_error,
             "last_error": self._last_error,
             "per_printer": self.get_per_printer(),
             "per_printer": self.get_per_printer(),
-            "thresholds": {"low": low, "high": high},
+            # Pausing needs the score divided by this factor to be failing.
+            "thresholds": {"low": low, "high": high, "pause": high * ESCALATING_FACTOR},
             "history": list(self._history),
             "history": list(self._history),
         }
         }
 
 

+ 99 - 41
backend/app/services/obico_smoothing.py

@@ -1,13 +1,25 @@
 """Temporal smoothing for Obico ML detection scores.
 """Temporal smoothing for Obico ML detection scores.
 
 
-Ports Obico's failure-detection math:
-- per-frame `current_p` = sum of detection confidences
-- `ewm_mean` = exponentially weighted mean (alpha = 2 / (span + 1), span = 12)
-- `rolling_mean_short` = ~310 frames of recent activity (≈52 min at 10s/frame)
-- `rolling_mean_long`  = ~7200 frames of long-term baseline noise
-- First `WARMUP_FRAMES` frames always report "safe" while the state settles
-- Final score = max(ewm_mean, rolling_mean_short - rolling_mean_long)
-- Thresholds: LOW < score < HIGH is "warning", >= HIGH is "failure"
+A port of Obico's own decision (obico-server ``lib/prediction.py``,
+``update_prediction_with_detections`` and ``is_failing``; parameters from
+``FD_1ST_GEN_PARAMS``):
+
+- per-frame ``current_p`` = sum of detection confidences
+- ``ewm_mean`` = exponentially weighted mean (alpha = 2 / (span + 1), span = 12)
+- ``rolling_mean_short`` = mean of the print's last 310 frames (~52 min at 10 s)
+- ``rolling_mean_long`` = mean of the printer's last 7200 frames (~20 h),
+  kept across prints: whatever the camera always sees (plate texture,
+  labels) is baseline, not failure (#3459)
+- the score is ``ewm_mean - rolling_mean_long``. Below LOW it is safe, above
+  HIGH failing; in between it is failing only when it also beats
+  ``(rolling_mean_short - rolling_mean_long) * 3.8``
+- "warning" is failing at the plain thresholds (Obico's alert); "failure"
+  is failing with the score divided by 1.75 (Obico's pause), so pausing
+  takes a much clearer signal than a notification
+- the first ``WARMUP_FRAMES`` frames of a print are always safe
+
+Obico multiplies the score by the printer's sensitivity; scaling the LOW/HIGH
+thresholds by its inverse is the same, and is what the status panel shows.
 """
 """
 
 
 import math
 import math
@@ -19,6 +31,8 @@ EWM_ALPHA = 2.0 / (EWM_SPAN + 1)
 ROLLING_SHORT = 310
 ROLLING_SHORT = 310
 ROLLING_LONG = 7200
 ROLLING_LONG = 7200
 WARMUP_FRAMES = 30
 WARMUP_FRAMES = 30
+ROLLING_MEAN_SHORT_MULTIPLE = 3.8
+ESCALATING_FACTOR = 1.75
 
 
 # Base thresholds; sensitivity multipliers adjust them
 # Base thresholds; sensitivity multipliers adjust them
 BASE_LOW = 0.38
 BASE_LOW = 0.38
@@ -36,57 +50,101 @@ def thresholds(sensitivity: str) -> tuple[float, float]:
     return BASE_LOW * mult, BASE_HIGH * mult
     return BASE_LOW * mult, BASE_HIGH * mult
 
 
 
 
+class _RollingMean:
+    """Exact mean of the last ``size`` values."""
+
+    def __init__(self, size: int):
+        self._buf: deque = deque(maxlen=size)
+        self._sum = 0.0
+
+    def add(self, value: float) -> None:
+        if len(self._buf) == self._buf.maxlen:
+            self._sum -= self._buf[0]
+        self._buf.append(value)
+        self._sum += value
+
+    @property
+    def mean(self) -> float:
+        return self._sum / len(self._buf) if self._buf else 0.0
+
+
+class Baseline(_RollingMean):
+    """A printer's long-term mean, kept across its prints (in memory)."""
+
+    def __init__(self):
+        super().__init__(ROLLING_LONG)
+
+
+@dataclass
+class Reading:
+    """One frame's smoothed state, as Obico's is_failing() sees it."""
+
+    score: float = 0.0  # ewm_mean - rolling_mean_long
+    short_rise: float = 0.0  # rolling_mean_short - rolling_mean_long
+    warmup: bool = True
+
+
 @dataclass
 @dataclass
 class PrintState:
 class PrintState:
-    """Per-print smoothing state. Reset when a new print starts."""
+    """Per-print smoothing state. Reset when a new print starts; the
+    baseline it is given belongs to the printer and outlives it."""
 
 
+    baseline: Baseline = field(default_factory=Baseline)
     frame_count: int = 0
     frame_count: int = 0
     ewm_mean: float = 0.0
     ewm_mean: float = 0.0
-    short_sum: float = 0.0
-    long_sum: float = 0.0
-    short_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_SHORT))
-    long_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_LONG))
+    short: _RollingMean = field(default_factory=lambda: _RollingMean(ROLLING_SHORT))
+    last: Reading = field(default_factory=Reading)
 
 
-    def update(self, current_p: float) -> float:
-        """Feed a new per-frame score and return the smoothed score.
-
-        Returns 0.0 during warmup so early noise doesn't trigger actions.
-        """
+    def update(self, current_p: float) -> Reading:
+        """Feed a new per-frame score and return the smoothed reading."""
         self.frame_count += 1
         self.frame_count += 1
-
         if self.frame_count == 1:
         if self.frame_count == 1:
             self.ewm_mean = current_p
             self.ewm_mean = current_p
         else:
         else:
             self.ewm_mean = EWM_ALPHA * current_p + (1 - EWM_ALPHA) * self.ewm_mean
             self.ewm_mean = EWM_ALPHA * current_p + (1 - EWM_ALPHA) * self.ewm_mean
-
-        if len(self.short_buf) == self.short_buf.maxlen:
-            self.short_sum -= self.short_buf[0]
-        self.short_buf.append(current_p)
-        self.short_sum += current_p
-
-        if len(self.long_buf) == self.long_buf.maxlen:
-            self.long_sum -= self.long_buf[0]
-        self.long_buf.append(current_p)
-        self.long_sum += current_p
-
-        if self.frame_count <= WARMUP_FRAMES:
-            return 0.0
-
-        short_mean = self.short_sum / len(self.short_buf)
-        long_mean = self.long_sum / len(self.long_buf)
-        return max(self.ewm_mean, short_mean - long_mean)
+        self.short.add(current_p)
+        self.baseline.add(current_p)
+        long_mean = self.baseline.mean
+        self.last = Reading(
+            score=self.ewm_mean - long_mean,
+            short_rise=self.short.mean - long_mean,
+            warmup=self.frame_count <= WARMUP_FRAMES,
+        )
+        return self.last
+
+
+def _is_failing(reading: Reading, sensitivity: str, escalating_factor: float) -> bool:
+    if reading.warmup:
+        return False
+    low, high = thresholds(sensitivity)
+    score = reading.score / escalating_factor
+    if score < low:
+        return False
+    if score > high:
+        return True
+    # Thresholds carry the sensitivity here; Obico scales the score instead,
+    # so the short-window check gets the same factor to compare like with like.
+    mult = SENSITIVITY_MULT.get(sensitivity, 1.0)
+    return score > reading.short_rise * ROLLING_MEAN_SHORT_MULTIPLE * mult
 
 
 
 
-def classify(score: float, sensitivity: str) -> str:
-    """Return 'safe', 'warning', or 'failure' for a smoothed score."""
-    low, high = thresholds(sensitivity)
-    if score >= high:
+def classify(reading: Reading, sensitivity: str) -> str:
+    """'safe', 'warning' (Obico would alert) or 'failure' (Obico would pause)."""
+    if _is_failing(reading, sensitivity, ESCALATING_FACTOR):
         return "failure"
         return "failure"
-    if score >= low:
+    if _is_failing(reading, sensitivity, 1.0):
         return "warning"
         return "warning"
     return "safe"
     return "safe"
 
 
 
 
+def triggers_action(verdict: str, action: str) -> bool:
+    """A notification goes out at Obico's alert level; pausing, with or
+    without power-off, waits for its pause level."""
+    if action == "notify":
+        return verdict in ("warning", "failure")
+    return verdict == "failure"
+
+
 def score_from_detections(detections: list) -> float:
 def score_from_detections(detections: list) -> float:
     """Sum confidences from the ML API `detections` array.
     """Sum confidences from the ML API `detections` array.
 
 

+ 6 - 3
backend/tests/unit/test_obico_detection.py

@@ -455,18 +455,21 @@ class TestPollOneStateLifecycle:
         }
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
 
-        # Seed state so the next frame crosses HIGH immediately
+        # Seed a clean print that then rises sharply, so the next frame is
+        # well past the pause level (a steady 1.0 would be baseline, #3459)
         from backend.app.services.obico_smoothing import PrintState
         from backend.app.services.obico_smoothing import PrintState
 
 
         seeded = PrintState()
         seeded = PrintState()
         for _ in range(WARMUP_FRAMES + 500):
         for _ in range(WARMUP_FRAMES + 500):
-            seeded.update(1.0)
+            seeded.update(0.0)
+        for _ in range(40):
+            seeded.update(3.0)
         svc._states[1] = seeded
         svc._states[1] = seeded
         svc._state_keys[1] = "job"
         svc._state_keys[1] = "job"
         svc._action_fired[1] = False
         svc._action_fired[1] = False
 
 
         mock_response = MagicMock()
         mock_response = MagicMock()
-        mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
+        mock_response.json.return_value = {"detections": [["failure", 3.0, [0, 0, 1, 1]]]}
         mock_response.raise_for_status = MagicMock()
         mock_response.raise_for_status = MagicMock()
         mock_client = MagicMock()
         mock_client = MagicMock()
         mock_client.get = AsyncMock(return_value=mock_response)
         mock_client.get = AsyncMock(return_value=mock_response)

+ 202 - 0
backend/tests/unit/test_obico_print_lifecycle_3459.py

@@ -0,0 +1,202 @@
+"""Obico detection keeps a print's state through a pause (#3459).
+
+Every poll that didn't see RUNNING used to wipe the print's smoothing and its
+"action fired", PAUSE included: a print Obico had paused was re-armed on
+resume, ran its warmup again, and could be paused again by the same false
+positive. The state now ends with the print (FINISH / FAILED / IDLE) or with
+a new job, and the printer's long-term baseline outlives its prints.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.obico_detection import ObicoDetectionService
+from backend.app.services.obico_smoothing import WARMUP_FRAMES
+
+FAKE_JPEG = b"\xff\xd8\xff\xd9"
+
+
+def _settings(action: str = "pause") -> dict:
+    return {
+        "enabled": True,
+        "ml_url": "http://obico:3333",
+        "sensitivity": "medium",
+        "action": action,
+        "poll_interval": 10,
+        "enabled_printers": None,
+        "snapshot_base_url": "http://bambuddy:8000",
+    }
+
+
+def _status(state: str = "RUNNING", task: str = "job", subtask_id: str | None = "111"):
+    return MagicMock(state=state, task_name=task, subtask_name="", subtask_id=subtask_id)
+
+
+class _Rig:
+    """Drives _poll_once with a printer status and an ML API answer."""
+
+    def __init__(self, svc: ObicoDetectionService):
+        self.svc = svc
+        self.p = 0.0
+        self.action = AsyncMock()
+
+    async def poll(self, status, settings: dict, times: int = 1) -> None:
+        response = MagicMock()
+        response.status_code = 200
+        response.raise_for_status = MagicMock()
+        response.json.side_effect = lambda: {"detections": [["failure", self.p, [0, 0, 1, 1]]] if self.p else []}
+        client = MagicMock()
+        client.get = AsyncMock(return_value=response)
+        client.__aenter__ = AsyncMock(return_value=client)
+        client.__aexit__ = AsyncMock(return_value=False)
+        manager = MagicMock()
+        manager.get_all_statuses.return_value = {1: status}
+        manager.is_connected.return_value = True
+        with (
+            patch.dict("sys.modules", {"backend.app.services.printer_manager": MagicMock(printer_manager=manager)}),
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=client),
+            patch("backend.app.services.obico_actions.execute_action", new=self.action),
+            patch.object(self.svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            for _ in range(times):
+                await self.svc._poll_once(settings)
+
+
+@pytest.fixture
+def rig():
+    return _Rig(ObicoDetectionService())
+
+
+async def _pause_by_obico(rig: _Rig) -> None:
+    rig.p = 0.0
+    await rig.poll(_status(), _settings(), times=WARMUP_FRAMES + 200)
+    rig.p = 3.0
+    await rig.poll(_status(), _settings(), times=40)
+    assert rig.action.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_a_resumed_print_keeps_its_state_and_is_not_paused_again(rig):
+    await _pause_by_obico(rig)
+    frames = rig.svc._states[1].frame_count
+
+    await rig.poll(_status("PAUSE"), _settings(), times=5)
+    assert rig.svc._states[1].frame_count == frames  # kept, not checked while paused
+    await rig.poll(_status(), _settings(), times=WARMUP_FRAMES + 10)
+
+    assert rig.svc._states[1].frame_count == frames + WARMUP_FRAMES + 10
+    assert rig.action.await_count == 1
+
+
+@pytest.mark.parametrize("ended", ["FINISH", "FAILED", "IDLE", "PREPARE", "SLICING"])
+@pytest.mark.asyncio
+async def test_the_state_ends_with_the_print(rig, ended):
+    await _pause_by_obico(rig)
+
+    await rig.poll(_status(ended), _settings())
+
+    assert 1 not in rig.svc._states
+    assert 1 not in rig.svc._action_fired
+
+
+@pytest.mark.asyncio
+async def test_another_job_of_the_same_file_starts_fresh(rig):
+    # Its FINISH and PREPARE both fell between two polls; same task name.
+    await _pause_by_obico(rig)
+
+    rig.p = 0.0
+    await rig.poll(_status(subtask_id="222"), _settings())
+
+    assert rig.svc._states[1].frame_count == 1
+    assert rig.svc._action_fired[1] is False
+
+
+@pytest.mark.asyncio
+async def test_a_reprint_without_a_job_id_starts_fresh_after_prepare(rig):
+    # A print from the SD card or the touchscreen may report no job id.
+    await _pause_by_obico(rig)
+    await rig.poll(_status("PREPARE", subtask_id=None), _settings())
+
+    rig.p = 0.0
+    await rig.poll(_status(subtask_id=None), _settings())
+
+    assert rig.svc._states[1].frame_count == 1
+    assert rig.svc._action_fired[1] is False
+
+
+@pytest.mark.asyncio
+async def test_a_job_id_arriving_late_is_the_same_print(rig):
+    await rig.poll(_status(subtask_id=None), _settings(), times=3)
+    await rig.poll(_status(subtask_id="111"), _settings(), times=2)
+
+    assert rig.svc._states[1].frame_count == 5
+
+
+@pytest.mark.asyncio
+async def test_the_baseline_carries_into_the_next_print(rig):
+    rig.p = 0.82  # the plate artwork, in every frame
+    await rig.poll(_status(), _settings(), times=3000)
+    await rig.poll(_status("FINISH"), _settings())
+
+    # The next print's camera only shows it once the plate is uncovered: on a
+    # printer that had never seen it, that is a sudden rise.
+    rig.p = 0.0
+    await rig.poll(_status(task="next", subtask_id="222"), _settings("notify"), times=200)
+    rig.p = 0.82
+    await rig.poll(_status(task="next", subtask_id="222"), _settings("notify"), times=40)
+
+    assert rig.svc._last_class[1] == "safe"
+    assert rig.action.await_count == 0
+
+
+@pytest.mark.asyncio
+async def test_the_status_shows_the_score_minus_the_baseline(rig):
+    rig.p = 0.82
+    await rig.poll(_status(), _settings(), times=WARMUP_FRAMES + 200)
+
+    assert rig.svc.get_per_printer()[1]["score"] == pytest.approx(0.0, abs=1e-3)
+
+    # Once a part covers what the baseline learned, the score drops below 0:
+    # shown as no detection, not as a negative number.
+    rig.p = 0.3  # still a detection, so it is logged
+    await rig.poll(_status(), _settings(), times=20)
+    assert rig.svc._states[1].last.score < 0
+    assert rig.svc.get_status()["history"]
+    assert rig.svc.get_per_printer()[1]["score"] == 0.0
+    assert all(entry["score"] >= 0 for entry in rig.svc.get_status()["history"])
+
+
+def test_the_status_shows_the_pause_level():
+    thresholds = ObicoDetectionService().get_status("medium")["thresholds"]
+    assert thresholds["pause"] == pytest.approx(0.78 * 1.75)
+
+
+@pytest.mark.asyncio
+async def test_a_new_print_forgets_the_old_job_id(rig):
+    # Task name changed while the new job id is still unknown: when it
+    # arrives it belongs to this print, not to a third one.
+    await rig.poll(_status(subtask_id="111"), _settings(), times=3)
+    await rig.poll(_status(task="next", subtask_id=None), _settings(), times=3)
+    await rig.poll(_status(task="next", subtask_id="222"), _settings(), times=2)
+
+    assert rig.svc._states[1].frame_count == 5
+
+
+@pytest.mark.asyncio
+async def test_forgetting_a_printer_drops_its_baseline(rig):
+    await rig.poll(_status(), _settings(), times=3)
+    rig.svc.forget_printer(1)
+    assert 1 not in rig.svc._baselines
+
+
+@pytest.mark.parametrize(("action", "fired"), [("notify", 1), ("pause", 0), ("pause_and_off", 0)])
+@pytest.mark.asyncio
+async def test_notify_fires_at_the_alert_level_and_pausing_waits(rig, action, fired):
+    # A slow build-up to 0.8 over ~25 minutes: Obico alerts, never pauses.
+    await rig.poll(_status(), _settings(action), times=1550)
+    for i in range(1, 300):
+        rig.p = 0.8 * min(1.0, i / 150)
+        await rig.poll(_status(), _settings(action))
+
+    assert rig.action.await_count == fired

+ 108 - 37
backend/tests/unit/test_obico_smoothing.py

@@ -1,15 +1,23 @@
-"""Unit tests for Obico detection smoothing math."""
+"""Unit tests for Obico detection smoothing math.
+
+The decision is Obico's own is_failing() (#3459): the score is the EWM minus
+the printer's long-term mean, so what the camera always sees is baseline.
+"""
 
 
 import pytest
 import pytest
 
 
 from backend.app.services.obico_smoothing import (
 from backend.app.services.obico_smoothing import (
     BASE_HIGH,
     BASE_HIGH,
     BASE_LOW,
     BASE_LOW,
+    ESCALATING_FACTOR,
     WARMUP_FRAMES,
     WARMUP_FRAMES,
+    Baseline,
     PrintState,
     PrintState,
+    Reading,
     classify,
     classify,
     score_from_detections,
     score_from_detections,
     thresholds,
     thresholds,
+    triggers_action,
 )
 )
 
 
 
 
@@ -47,52 +55,115 @@ class TestScoreFromDetections:
         assert score_from_detections(dets) == pytest.approx(0.4)
         assert score_from_detections(dets) == pytest.approx(0.4)
 
 
 
 
+def _feed(state: PrintState, value: float, frames: int) -> Reading:
+    reading = Reading()
+    for _ in range(frames):
+        reading = state.update(value)
+    return reading
+
+
 class TestPrintState:
 class TestPrintState:
-    def test_warmup_returns_zero(self):
+    def test_warmup_is_always_safe(self):
         state = PrintState()
         state = PrintState()
         for _ in range(WARMUP_FRAMES):
         for _ in range(WARMUP_FRAMES):
-            assert state.update(0.9) == 0.0
+            assert classify(state.update(5.0), "high") == "safe"
 
 
-    def test_after_warmup_returns_nonzero_for_hits(self):
+    def test_sustained_zero_stays_safe(self):
         state = PrintState()
         state = PrintState()
-        for _ in range(WARMUP_FRAMES):
-            state.update(0.9)
-        score = state.update(0.9)
-        assert score > 0.0
+        assert classify(_feed(state, 0.0, WARMUP_FRAMES + 50), "high") == "safe"
 
 
-    def test_sustained_zero_stays_safe(self):
+    def test_a_steady_background_from_the_start_is_baseline(self):
+        # The plate artwork in #3459: 5 boxes summing to 0.82 in every frame.
+        # The raw EWM sat above HIGH; minus the baseline it is nothing.
         state = PrintState()
         state = PrintState()
-        scores = [state.update(0.0) for _ in range(WARMUP_FRAMES + 50)]
-        assert max(scores) == 0.0
+        reading = _feed(state, 0.82, WARMUP_FRAMES + 2000)
+        assert state.ewm_mean > BASE_HIGH
+        assert reading.score == pytest.approx(0.0, abs=1e-9)
+        assert classify(reading, "high") == "safe"
+
+    def test_the_baseline_outlives_the_print(self):
+        # Seen on every print of this printer: the next print starts with it.
+        baseline = Baseline()
+        _feed(PrintState(baseline=baseline), 0.82, 3000)
+        reading = _feed(PrintState(baseline=baseline), 0.82, WARMUP_FRAMES + 5)
+        assert classify(reading, "medium") == "safe"
+        # Without it, the same frames appearing on a clean printer are a
+        # sudden sustained rise, which is what Obico pauses for.
+        fresh = PrintState()
+        _feed(fresh, 0.0, 600)
+        assert classify(_feed(fresh, 0.82, 40), "medium") == "failure"
+
+    def test_a_sudden_sustained_rise_pauses(self):
+        state = PrintState()
+        _feed(state, 0.0, 600)
+        reading = _feed(state, 3.0, 40)
+        assert reading.score / ESCALATING_FACTOR > BASE_HIGH
+        assert classify(reading, "medium") == "failure"
+
+    def test_a_late_sudden_background_is_a_rise_then_settles(self):
+        # #3459: ~4 h of nothing, then boxes (~0.8) in every frame. That is a
+        # sudden sustained rise, which Obico pauses for, like spaghetti; once
+        # the last ~52 min account for it, it is no longer failing.
+        state = PrintState()
+        _feed(state, 0.0, 1550)
+        verdicts = [classify(state.update(0.8), "medium") for _ in range(200)]
+        assert "failure" in verdicts[:40]
+        assert verdicts[-40:] == ["safe"] * 40
 
 
-    def test_sustained_hits_eventually_cross_high(self):
-        """A stream of high-confidence frames must escalate to 'failure'."""
+    def test_a_slow_build_up_warns_without_pausing(self):
+        # The same 0.8 reached over ~25 minutes: Obico alerts, never pauses.
         state = PrintState()
         state = PrintState()
-        final = 0.0
-        for _ in range(WARMUP_FRAMES + 200):
-            final = state.update(1.0)
-        _, high = thresholds("medium")
-        assert final >= high
-
-    def test_isolated_spike_does_not_trigger_failure(self):
-        """A single noisy frame in a clean stream must not cross HIGH."""
+        _feed(state, 0.0, 1550)
+        verdicts = {classify(state.update(0.8 * min(1.0, i / 150)), "medium") for i in range(1, 300)}
+        assert verdicts == {"safe", "warning"}
+
+    def test_in_the_band_the_short_window_rise_must_be_beaten(self):
+        # Between LOW and HIGH, a score that a slow build-up over the last
+        # ~52 min accounts for (x3.8) is not failing.
+        between = (BASE_LOW + BASE_HIGH) / 2
+        assert classify(Reading(score=between, short_rise=between / 3.8 + 0.01, warmup=False), "medium") == "safe"
+        assert classify(Reading(score=between, short_rise=between / 3.8 - 0.01, warmup=False), "medium") == "warning"
+
+    def test_an_isolated_spike_does_not_alert(self):
         state = PrintState()
         state = PrintState()
-        for _ in range(WARMUP_FRAMES):
-            state.update(0.0)
-        score = state.update(1.0)
-        _, high = thresholds("medium")
-        assert score < high
+        _feed(state, 0.0, WARMUP_FRAMES + 100)
+        assert classify(state.update(1.0), "medium") == "safe"
 
 
 
 
 class TestClassify:
 class TestClassify:
-    def test_safe(self):
-        assert classify(0.0, "medium") == "safe"
-        assert classify(BASE_LOW - 0.01, "medium") == "safe"
-
-    def test_warning(self):
-        assert classify(BASE_LOW, "medium") == "warning"
-        assert classify((BASE_LOW + BASE_HIGH) / 2, "medium") == "warning"
-
-    def test_failure(self):
-        assert classify(BASE_HIGH, "medium") == "failure"
-        assert classify(1.0, "medium") == "failure"
+    @staticmethod
+    def _r(score: float, short_rise: float = 0.0) -> Reading:
+        return Reading(score=score, short_rise=short_rise, warmup=False)
+
+    def test_safe_below_low(self):
+        assert classify(self._r(BASE_LOW - 0.01), "medium") == "safe"
+
+    def test_warning_above_high(self):
+        assert classify(self._r(BASE_HIGH + 0.01, short_rise=1.0), "medium") == "warning"
+
+    def test_failure_needs_the_escalated_score(self):
+        assert classify(self._r(BASE_HIGH * ESCALATING_FACTOR - 0.01, short_rise=1.0), "medium") == "warning"
+        assert classify(self._r(BASE_HIGH * ESCALATING_FACTOR + 0.01), "medium") == "failure"
+
+    def test_sensitivity_scales_the_short_window_check_too(self):
+        # Obico multiplies the score by the sensitivity; we scale thresholds.
+        # Medium in the band, 0.5 vs 0.13 * 3.8 = 0.494: failing. Low (x1.25
+        # thresholds, 0.475..0.975): 0.5 vs 0.494 * 1.25 = 0.6175: not.
+        r = self._r(0.5, short_rise=0.13)
+        assert classify(r, "medium") == "warning"
+        assert classify(r, "low") == "safe"
+
+    def test_warmup_is_safe_whatever_the_score(self):
+        assert classify(Reading(score=10.0, short_rise=0.0, warmup=True), "high") == "safe"
+
+
+class TestTriggersAction:
+    def test_notify_fires_at_the_alert_level(self):
+        assert triggers_action("warning", "notify")
+        assert triggers_action("failure", "notify")
+        assert not triggers_action("safe", "notify")
+
+    @pytest.mark.parametrize("action", ["pause", "pause_and_off"])
+    def test_pausing_waits_for_the_pause_level(self, action):
+        assert not triggers_action("warning", action)
+        assert triggers_action("failure", action)

+ 33 - 0
frontend/src/__tests__/components/ObicoSettings.test.tsx

@@ -0,0 +1,33 @@
+/**
+ * The Obico status panel shows the pause level next to Low / High (#3459):
+ * pausing waits for Obico's pause level (High x 1.75), so a score above High
+ * alone is a warning.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import { ObicoSettings } from '../../components/ObicoSettings';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({ obico_enabled: true, obico_ml_url: 'http://obico:3333' }),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getObicoStatus: vi.fn().mockResolvedValue({
+      is_running: true,
+      last_error: null,
+      per_printer: {},
+      thresholds: { low: 0.38, high: 0.78, pause: 1.4 },
+      history: [],
+    }),
+  },
+}));
+
+describe('ObicoSettings status panel', () => {
+  it('shows the pause level', async () => {
+    render(<ObicoSettings />);
+
+    expect(await screen.findByText('0.38 / 0.78')).toBeInTheDocument();
+    expect(screen.getByText('1.40')).toBeInTheDocument();
+  });
+});

+ 1 - 1
frontend/src/api/client.ts

@@ -3628,7 +3628,7 @@ export interface ObicoStatus {
   is_running: boolean;
   is_running: boolean;
   last_error: string | null;
   last_error: string | null;
   per_printer: Record<string, { class: string; frame_count: number; score: number }>;
   per_printer: Record<string, { class: string; frame_count: number; score: number }>;
-  thresholds: { low: number; high: number };
+  thresholds: { low: number; high: number; pause?: number };
   history: ObicoDetectionEvent[];
   history: ObicoDetectionEvent[];
   enabled: boolean;
   enabled: boolean;
   ml_url: string;
   ml_url: string;

+ 6 - 0
frontend/src/components/ObicoSettings.tsx

@@ -412,6 +412,12 @@ export function ObicoSettings() {
                     {status.thresholds.low.toFixed(2)} / {status.thresholds.high.toFixed(2)}
                     {status.thresholds.low.toFixed(2)} / {status.thresholds.high.toFixed(2)}
                   </span>
                   </span>
                 </div>
                 </div>
+                {status.thresholds.pause != null && (
+                  <div className="flex justify-between">
+                    <span className="text-bambu-gray">{t('failureDetection.pauseThreshold')}</span>
+                    <span className="text-white font-mono">{status.thresholds.pause.toFixed(2)}</span>
+                  </div>
+                )}
                 {status.last_error && (
                 {status.last_error && (
                   <div className="flex items-start gap-2 text-red-700 dark:text-red-400">
                   <div className="flex items-start gap-2 text-red-700 dark:text-red-400">
                     <X className="w-4 h-4 mt-0.5 flex-shrink-0" />
                     <X className="w-4 h-4 mt-0.5 flex-shrink-0" />

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

@@ -8013,6 +8013,7 @@ export default {
     statusTitle: 'Status',
     statusTitle: 'Status',
     serviceRunning: 'Dienst läuft',
     serviceRunning: 'Dienst läuft',
     thresholds: 'Niedrig / Hoch-Schwellwerte',
     thresholds: 'Niedrig / Hoch-Schwellwerte',
+    pauseThreshold: 'Pause-Schwelle',
     activePrinters: 'Aktive Drucke',
     activePrinters: 'Aktive Drucke',
     noActivePrints: 'Derzeit laufen keine Drucke.',
     noActivePrints: 'Derzeit laufen keine Drucke.',
     historyTitle: 'Letzte Erkennungen',
     historyTitle: 'Letzte Erkennungen',

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

@@ -8066,6 +8066,7 @@ export default {
     statusTitle: 'Status',
     statusTitle: 'Status',
     serviceRunning: 'Service running',
     serviceRunning: 'Service running',
     thresholds: 'Low / High thresholds',
     thresholds: 'Low / High thresholds',
+    pauseThreshold: 'Pause level',
     activePrinters: 'Active prints',
     activePrinters: 'Active prints',
     noActivePrints: 'No prints currently running.',
     noActivePrints: 'No prints currently running.',
     historyTitle: 'Recent Detections',
     historyTitle: 'Recent Detections',

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

@@ -8021,6 +8021,7 @@ export default {
     statusTitle: 'Estado',
     statusTitle: 'Estado',
     serviceRunning: 'Servicio en ejecución',
     serviceRunning: 'Servicio en ejecución',
     thresholds: 'Umbrales bajo / alto',
     thresholds: 'Umbrales bajo / alto',
+    pauseThreshold: 'Umbral de pausa',
     activePrinters: 'Impresiones activas',
     activePrinters: 'Impresiones activas',
     noActivePrints: 'No hay impresiones en curso actualmente.',
     noActivePrints: 'No hay impresiones en curso actualmente.',
     historyTitle: 'Detecciones recientes',
     historyTitle: 'Detecciones recientes',

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

@@ -8001,6 +8001,7 @@ export default {
     statusTitle: 'Statut',
     statusTitle: 'Statut',
     serviceRunning: 'Service en cours d\'exécution',
     serviceRunning: 'Service en cours d\'exécution',
     thresholds: 'Seuils bas / haut',
     thresholds: 'Seuils bas / haut',
+    pauseThreshold: 'Seuil de pause',
     activePrinters: 'Impressions actives',
     activePrinters: 'Impressions actives',
     noActivePrints: 'Aucune impression en cours.',
     noActivePrints: 'Aucune impression en cours.',
     historyTitle: 'Détections récentes',
     historyTitle: 'Détections récentes',

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

@@ -8000,6 +8000,7 @@ export default {
     statusTitle: 'Stato',
     statusTitle: 'Stato',
     serviceRunning: 'Servizio in esecuzione',
     serviceRunning: 'Servizio in esecuzione',
     thresholds: 'Soglie bassa / alta',
     thresholds: 'Soglie bassa / alta',
+    pauseThreshold: 'Soglia di pausa',
     activePrinters: 'Stampe attive',
     activePrinters: 'Stampe attive',
     noActivePrints: 'Nessuna stampa in corso.',
     noActivePrints: 'Nessuna stampa in corso.',
     historyTitle: 'Rilevamenti recenti',
     historyTitle: 'Rilevamenti recenti',

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

@@ -8014,6 +8014,7 @@ export default {
     statusTitle: 'ステータス',
     statusTitle: 'ステータス',
     serviceRunning: 'サービス稼働中',
     serviceRunning: 'サービス稼働中',
     thresholds: '低 / 高しきい値',
     thresholds: '低 / 高しきい値',
+    pauseThreshold: '一時停止しきい値',
     activePrinters: 'アクティブな印刷',
     activePrinters: 'アクティブな印刷',
     noActivePrints: '現在、実行中の印刷はありません。',
     noActivePrints: '現在、実行中の印刷はありません。',
     historyTitle: '最近の検出',
     historyTitle: '最近の検出',

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

@@ -7456,6 +7456,7 @@ export default {
     statusTitle: '상태',
     statusTitle: '상태',
     serviceRunning: '서비스 실행 중',
     serviceRunning: '서비스 실행 중',
     thresholds: '낮음 / 높음 임계값',
     thresholds: '낮음 / 높음 임계값',
+    pauseThreshold: '일시정지 임계값',
     activePrinters: '활성 인쇄물',
     activePrinters: '활성 인쇄물',
     noActivePrints: '현재 실행 중인 인쇄물 없음.',
     noActivePrints: '현재 실행 중인 인쇄물 없음.',
     historyTitle: '최근 감지',
     historyTitle: '최근 감지',

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

@@ -8064,6 +8064,7 @@ export default {
     statusTitle: 'Status',
     statusTitle: 'Status',
     serviceRunning: 'Service actief',
     serviceRunning: 'Service actief',
     thresholds: 'Lage / hoge drempels',
     thresholds: 'Lage / hoge drempels',
+    pauseThreshold: 'Pauzedrempel',
     activePrinters: 'Actieve afdrukken',
     activePrinters: 'Actieve afdrukken',
     noActivePrints: 'Er worden momenteel geen afdrukken uitgevoerd.',
     noActivePrints: 'Er worden momenteel geen afdrukken uitgevoerd.',
     historyTitle: 'Recente detecties',
     historyTitle: 'Recente detecties',

+ 1 - 0
frontend/src/i18n/locales/pl.ts

@@ -8066,6 +8066,7 @@ export default {
     statusTitle: 'Status',
     statusTitle: 'Status',
     serviceRunning: 'Usługa działa',
     serviceRunning: 'Usługa działa',
     thresholds: 'Progi niski / wysoki',
     thresholds: 'Progi niski / wysoki',
+    pauseThreshold: 'Próg pauzy',
     activePrinters: 'Aktywne wydruki',
     activePrinters: 'Aktywne wydruki',
     noActivePrints: 'Obecnie nie trwają żadne wydruki.',
     noActivePrints: 'Obecnie nie trwają żadne wydruki.',
     historyTitle: 'Ostatnie wykrycia',
     historyTitle: 'Ostatnie wykrycia',

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

@@ -8000,6 +8000,7 @@ export default {
     statusTitle: 'Status',
     statusTitle: 'Status',
     serviceRunning: 'Serviço em execução',
     serviceRunning: 'Serviço em execução',
     thresholds: 'Limiares baixo / alto',
     thresholds: 'Limiares baixo / alto',
+    pauseThreshold: 'Limiar de pausa',
     activePrinters: 'Impressões ativas',
     activePrinters: 'Impressões ativas',
     noActivePrints: 'Nenhuma impressão em andamento.',
     noActivePrints: 'Nenhuma impressão em andamento.',
     historyTitle: 'Detecções recentes',
     historyTitle: 'Detecções recentes',

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

@@ -7640,6 +7640,7 @@ export default {
     statusTitle: "Состояние",
     statusTitle: "Состояние",
     serviceRunning: "Служба работает",
     serviceRunning: "Служба работает",
     thresholds: "Низкий / высокий порог",
     thresholds: "Низкий / высокий порог",
+    pauseThreshold: "Порог паузы",
     activePrinters: "Активные задания",
     activePrinters: "Активные задания",
     noActivePrints: "Сейчас ничего не печатается.",
     noActivePrints: "Сейчас ничего не печатается.",
     historyTitle: "Последние обнаружения",
     historyTitle: "Последние обнаружения",

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

@@ -8062,6 +8062,7 @@ errors: {
     statusTitle: 'Status',
     statusTitle: 'Status',
     serviceRunning: 'Tjänst körs',
     serviceRunning: 'Tjänst körs',
     thresholds: 'Låga / Hög tröskelvärden',
     thresholds: 'Låga / Hög tröskelvärden',
+    pauseThreshold: 'Pauströskel',
     activePrinters: 'Aktiva utskrifter',
     activePrinters: 'Aktiva utskrifter',
     noActivePrints: 'Inga utskrifter körs för närvarande.',
     noActivePrints: 'Inga utskrifter körs för närvarande.',
     historyTitle: 'Senaste upptäckter',
     historyTitle: 'Senaste upptäckter',

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

@@ -7952,6 +7952,7 @@ export default {
     statusTitle: 'Durum',
     statusTitle: 'Durum',
     serviceRunning: 'Servis çalışıyor',
     serviceRunning: 'Servis çalışıyor',
     thresholds: 'Düşük / Yüksek eşikler',
     thresholds: 'Düşük / Yüksek eşikler',
+    pauseThreshold: 'Duraklatma eşiği',
     activePrinters: 'Aktif baskılar',
     activePrinters: 'Aktif baskılar',
     noActivePrints: 'Şu anda çalışan baskı yok.',
     noActivePrints: 'Şu anda çalışan baskı yok.',
     historyTitle: 'Son Algılamalar',
     historyTitle: 'Son Algılamalar',

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

@@ -8054,6 +8054,7 @@ export default {
     statusTitle: "Статус",
     statusTitle: "Статус",
     serviceRunning: "Служба працює",
     serviceRunning: "Служба працює",
     thresholds: "Нижній / верхній поріг",
     thresholds: "Нижній / верхній поріг",
+    pauseThreshold: "Поріг паузи",
     activePrinters: "Активні завдання друку",
     activePrinters: "Активні завдання друку",
     noActivePrints: "Зараз немає роздруківок.",
     noActivePrints: "Зараз немає роздруківок.",
     historyTitle: "Останні виявлення",
     historyTitle: "Останні виявлення",

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

@@ -7998,6 +7998,7 @@ export default {
     statusTitle: '状态',
     statusTitle: '状态',
     serviceRunning: '服务运行中',
     serviceRunning: '服务运行中',
     thresholds: '低 / 高阈值',
     thresholds: '低 / 高阈值',
+    pauseThreshold: '暂停阈值',
     activePrinters: '活动打印',
     activePrinters: '活动打印',
     noActivePrints: '当前没有正在进行的打印。',
     noActivePrints: '当前没有正在进行的打印。',
     historyTitle: '最近检测',
     historyTitle: '最近检测',

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

@@ -7998,6 +7998,7 @@ export default {
     statusTitle: '狀態',
     statusTitle: '狀態',
     serviceRunning: '服務執行中',
     serviceRunning: '服務執行中',
     thresholds: '低 / 高閾值',
     thresholds: '低 / 高閾值',
+    pauseThreshold: '暫停閾值',
     activePrinters: '活動列印',
     activePrinters: '活動列印',
     noActivePrints: '目前沒有正在進行的列印。',
     noActivePrints: '目前沒有正在進行的列印。',
     historyTitle: '最近檢測',
     historyTitle: '最近檢測',