mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
Do not report a printer as Safe when nothing is checking it (issue #2952)
The printer card's AI badge collapsed every class that was not Warning or
Failure into green Safe, and the service reported `safe` whenever it had no
verdict. The state entry is created when a monitored print is first seen --
before the first snapshot, let alone the first inference -- so a rejected ML
API token, an unreachable ML API, a failed capture and an unset External URL
all rendered as a healthy watched print: green Safe at score 0.000.
For a safety feature that is the worst failure mode available: it asserts the
print is being watched exactly when it is not. The reporter read that badge and
concluded the loop had never started. It had been calling the ML API every ten
seconds and being turned away with a 401 -- invisible because Obico's auth
layer rejects a bad token before its request log sees it, and because
successful checks log nothing there either.
Add two honest states. Not checking (amber) when the last poll produced no
result, carrying the reason; Starting while a monitored print waits for its
first result. Score and frame count are withheld while not checking, since
0.000 beside "Not checking" reads as a measurement rather than its absence.
The reason is per printer, so a card names its own problem rather than
whichever printer failed most recently, and stays behind settings:read because
it can quote configured URLs -- the badge state does not, because whether a
print is watched is not configuration. An unrecognised class now falls back to
Starting, not Safe.
Test Connection saves the form before probing, so a green result describes the
configuration the loop actually runs with rather than what is typed in the
boxes.
This commit is contained in:
@@ -21,6 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Generated thumbnails are lit, so one model no longer looks like the next (#2816, requested by @NaegeliJ, contributed by @sadontsev in #2861)** — Both renderers that draw a model themselves — the File Manager's STL/3MF thumbnails and the plate cards — handed matplotlib a mesh with no light source, and without one every triangle is filled with the identical green whichever way it faces. The result was a flat silhouette, so two models of similar outline were the same picture. The mesh is now shaded from a fixed light whose angle is pinned to the camera angle rather than chosen freely: the two are a pair, and a light aimed at the far side of the model gives both visible faces the same brightness and no contrast at all. Lighting then exposed two faults a flat render had hidden. A face wound the wrong way shades as though it faced away, so an STL with inconsistent winding came out patchy like camouflage; winding is now repaired before the render — outward rather than merely consistent, and only for the meshes that need it, since the check is milliseconds where the repair is seconds. And a mesh whose facets are all zero-area or collinear — stub or truncated STLs, 3MFs with an empty triangle list — would have failed outright once lit, so those are detected and still render flat instead of counting as a failure in a folder-wide batch. Both renderers share one copy of the light, the camera and the repair, so a plate card and a library thumbnail of the same model cannot drift apart. Passing the faces as an array rather than a list of lists also cut the collection build on an 82k-face mesh from ~0.19s to ~0.007s, which speeds up the unlit path too.
|
||||
|
||||
### Fixed
|
||||
- **A printer whose failure detection was not working showed a green "Safe" badge (#2952, reported via in-app bug report, with a full ML API and Bambuddy log correlation)** — The printer card's AI badge collapsed every class that was not Warning or Failure into green **Safe**, and the service reported `safe` whenever it had no verdict to give. The state entry is created the moment a monitored print is seen, which is before the first snapshot and well before the first inference, so a rejected ML API token, an unreachable ML API, a camera that never yielded a frame and an unset External URL all rendered identically to a healthy watched print: a green pill reading Safe at score 0.000. For a safety feature that is the worst available failure mode — it asserts the print is being watched at exactly the moment it is not. The reporter read that badge, concluded the detection loop had never started, and spent an evening ruling out the network path; the loop had in fact been calling the ML API every ten seconds the whole time and being turned away with a 401. What hid it is that Obico's auth layer rejects a bad token before its own request log records anything, so a token problem and a service that never runs leave the same trace in the ML container: nothing. Successful checks log nothing there either, which leaves the container log unable to distinguish working from broken in either direction. The badge now has two more states and tells the truth in both: **Not checking** (amber) when the last check produced no result, carrying the reason — rejected token, unreachable API, failed capture, missing External URL — in the tooltip and the modal, and **Starting** while a monitored print waits for its first result. Score and frame count are withheld while a printer is not being checked, since 0.000 next to "Not checking" reads as a measurement rather than the absence of one. The reason is tracked per printer rather than service-wide, so on a multi-printer setup a card names its own problem instead of whichever printer failed most recently; it stays behind `settings:read` because it can quote configured URLs, while the badge state itself does not, because whether a print is being watched is not configuration. An unrecognised class from a future backend now falls back to **Starting** rather than **Safe** — the fallback that caused this. Also fixed alongside: **Test Connection** now saves the form before probing, so a green "reachable and healthy" always describes the configuration the detection loop is actually running with rather than what is typed in the boxes. The wiki's badge table and troubleshooting section are updated, including the ML API container log being unable to answer this question.
|
||||
- **Bambuddy could not start against a PostgreSQL server whose messages are not in English (#2949, reported and diagnosed by @dvb6666)** — @dvb6666's Debian PostgreSQL 15 answered the first migration statement with `столбец "parent_run_id" отношения "pipeline_runs" уже существует` and startup aborted. The column already existing is the *expected* outcome there: `create_all()` builds the tables from the models before the migration list runs, so on a fresh database essentially every `ADD COLUMN` in that list is a duplicate by design, and all 382 of them relied on the runner recognising "already applied" and moving on. It recognised it by searching the error text for `already exists` — but PostgreSQL renders its messages in the server's own `lc_messages` locale, and a Russian server does not say that. So the very first statement was re-raised as a fatal error. This was never specific to `parent_run_id` or to one PostgreSQL version, which is why @dvb6666 got the identical failure after moving from 15 to 18: no PostgreSQL server outside an English locale could start Bambuddy at all, fresh install or upgrade, and the failure surfaced as a schema error rather than as anything pointing at the language. Idempotency is now decided by SQLSTATE, the five-character code PostgreSQL never translates — `42701` duplicate column, `42P07` duplicate table or index, `42710` duplicate constraint or trigger, `23505` duplicate key. The narrowing that was already there is kept and now rests on a code rather than a phrase: a missing column counts as "already applied" only for `RENAME COLUMN`, so a column missing during `ADD COLUMN` or `CREATE INDEX` still aborts startup instead of quietly hiding a corrupt schema — as do a missing table, an unknown column type, and a syntax error, each verified against a live server. SQLite keeps the text match, since its driver publishes no SQLSTATE and it never localises its messages. The same treatment went to the second place that read message text, the OIDC auto-link safety constraint, where a mistranslated "already exists" would have failed startup on the same servers. Measured rather than reasoned about: against PostgreSQL 15 under `ru_RU`, `en_US` and `C`, a full `init_db()` completes on both a fresh database and a re-run, and the schema the Russian server ends up with is byte-identical to the English one — nothing is being silently skipped to make startup succeed.
|
||||
- **A test in the plug-energy suite failed for 31 minutes of every day (#2938, reported and measured by @ojimpo)** — `test_nothing_derivable_before_the_first_midnight` passed for 23.5 hours a day and failed for the other half hour, which is the shape that reads as ordinary flakiness and gets re-run rather than fixed. @ojimpo hit it running the full suite for an unrelated PR at 22:10 UTC, stashed his branch to confirm it reproduced on clean `dev`, then measured the window minute by minute against the real day boundary instead of guessing at it. **The test's premise, not the product.** It asserts that nothing can be derived when a plug's only snapshot was taken *after* this local midnight, and it placed that snapshot at a raw wall-clock offset — `now - 30 minutes`. The comment beside it, "taken this morning, after midnight", is the premise, and it is only true away from the boundary: for the first half hour of each local day, `now - 30 minutes` lands *before* local midnight, where it is a perfectly good baseline. `_counter_at` finds it, and `today` comes back `103.5 - 102.0 = 1.5` instead of `None`. The window is local 00:00–00:30, which is 22:00–22:30 UTC while CEST is in effect and 23:00–23:30 under CET — it moves with DST, because the module pins `Europe/Berlin` in an autouse fixture and an outer `TZ` makes no difference to it. **`derive_today_yesterday` is right and unchanged.** A snapshot from before local midnight genuinely *is* a valid baseline for today, and the production code treating it as one is the documented behaviour, so nothing here reaches a running install. The snapshot is now anchored to `local_day_start(now) + 30 minutes`, which is the idiom the file's other nine snapshot writes already use and the reason none of *them* could drift — this was the only one that offset from the wall clock, and the only one that failed. Verified by replaying 5760 minutes across four days including both DST switch days: the old expression fails 31 minutes per day, the new one fails none. **Scope.** One test file. No product code, no DB migration, no schema change, no permission, no i18n change.
|
||||
- **The slice dialog took settings from the file with "Use the file's built-in settings" switched off (#2942, reported by @zevulos)** — Two separate features read as one. The checkbox slices a 3MF the way its designer set it up, ignoring the picked profiles; the per-option "from file" ticks beside each setting carry the designer's individual deviations onto the profile you picked (#2622), and those arrived pre-ticked whatever the checkbox said. So a slice run deliberately *without* the file's settings still took sixteen values out of it — the reporter's own log names them, `enable_support` and `support_type` among them, landing on a process preset they had chosen on purpose. The ticks now follow the checkbox: off, nothing comes out of the file until it is asked for by name; on, every setting the file changed shows ticked, because on that path the file really does drive the whole slice. Taking the designer's work in bulk is still one click, from a line at the top of the settings panel that says how many settings the file changed — and it still leaves the machine-tuned ones and the two that *are* the picked preset for a per-key decision, which is the classification #2622 made and this does not widen. Two things underneath had to change for the checkbox to mean what it says. The panel greys out options the slicer's own rules switch off, and it was evaluating those rules against what the user had typed alone, falling back to the compiled-in schema defaults for the rest — so a preset with supports on read as `enable_support: false` and greyed out the whole Support page while the slice ran supports. A greyed row greyed its tick too, which is how the reporter's screenshot shows a support type marked "from file", applied to the slice, and impossible to clear. The rules now see what the slice will actually run with: the preset's values, the file's values for the keys that are on, and anything typed on top. And the tick is no longer gated on the slicer's rules at all, because it answers a different question — not whether an option is in play, but where its value comes from. Separately, the support carry-over from #1881 ran underneath the ticks entirely, lifting four support keys out of any 3MF that had supports on, with nothing on screen to decline. It now stands down for the keys that were offered and turned down, which the request can say for the first time: an empty `design_overrides` list means the caller was shown the file's settings and took none, where no list at all is a caller that predates the choice and keeps #1881 whole — as do sources that record no deviations to tick, an OrcaSlicer export among them. Covered by backend and frontend tests, and measured against the reporter's own sixteen keys. Note the practical change: a MakerWorld file with supports enabled no longer switches supports on for you — tick **Enable support** in the panel, or the checkbox above it, if that is what you want.
|
||||
|
||||
@@ -53,11 +53,17 @@ async def get_printer_status(
|
||||
# Error strings can embed configured URLs (ML API base, external URL), so
|
||||
# they stay behind settings:read like the rest of the configuration.
|
||||
can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
|
||||
per_printer = obico_detection_service.get_per_printer()
|
||||
if not can_see_error:
|
||||
# The "error" *class* is not configuration — a printers:read user still
|
||||
# needs to know their print is not being watched. Only the reason, which
|
||||
# can name a URL, is withheld.
|
||||
per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
|
||||
return {
|
||||
"enabled": settings["enabled"],
|
||||
# None = all printers are monitored
|
||||
"monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
|
||||
"per_printer": obico_detection_service.get_per_printer(),
|
||||
"per_printer": per_printer,
|
||||
"last_error": obico_detection_service._last_error if can_see_error else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,14 @@ class ObicoDetectionService:
|
||||
self._states: dict[int, PrintState] = {}
|
||||
# printer_id -> task_name active when state was created (used to detect new prints)
|
||||
self._state_keys: dict[int, str] = {}
|
||||
# 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
|
||||
# means "we have no verdict", which is not the same as "safe" (#2952).
|
||||
self._last_class: dict[int, str] = {}
|
||||
# printer_id -> why the most recent poll produced no verdict, or absent
|
||||
# when the last poll succeeded. Per-printer rather than global so a card
|
||||
# can say what went wrong for *that* printer.
|
||||
self._errors: dict[int, str] = {}
|
||||
# printer_id -> whether an action has already been fired for the current print
|
||||
self._action_fired: dict[int, bool] = {}
|
||||
# Global detection event log (most-recent-first)
|
||||
@@ -191,6 +197,8 @@ class ObicoDetectionService:
|
||||
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)
|
||||
continue
|
||||
|
||||
await self._check_printer(printer_id, status, settings)
|
||||
@@ -261,6 +269,17 @@ class ObicoDetectionService:
|
||||
timeout=SNAPSHOT_CAPTURE_TIMEOUT,
|
||||
)
|
||||
|
||||
def _no_verdict(self, printer_id: int, reason: str) -> None:
|
||||
"""Record that this poll produced no verdict for ``printer_id``.
|
||||
|
||||
Kept separate from the classification so the status surface can say
|
||||
"not checking" instead of inheriting the previous verdict — or, worse,
|
||||
the default "safe" a printer used to get before its first inference.
|
||||
"""
|
||||
self._errors[printer_id] = reason
|
||||
self._last_error = reason
|
||||
logger.warning(reason)
|
||||
|
||||
async def _check_printer(self, printer_id: int, status, settings: dict):
|
||||
task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
|
||||
key = f"{task_name}"
|
||||
@@ -275,17 +294,16 @@ class ObicoDetectionService:
|
||||
# keyframe wait.
|
||||
frame = await self._capture_frame(printer_id)
|
||||
if not frame:
|
||||
self._last_error = f"Failed to capture snapshot for printer {printer_id}"
|
||||
logger.warning(self._last_error)
|
||||
self._no_verdict(printer_id, f"Failed to capture snapshot for printer {printer_id}")
|
||||
return
|
||||
|
||||
external_url = settings.get("external_url") or ""
|
||||
if not external_url:
|
||||
self._last_error = (
|
||||
self._no_verdict(
|
||||
printer_id,
|
||||
"external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
|
||||
"Set Settings → General → External URL."
|
||||
"Set Settings → General → External URL.",
|
||||
)
|
||||
logger.warning(self._last_error)
|
||||
return
|
||||
|
||||
nonce = await stash_frame(frame)
|
||||
@@ -304,19 +322,23 @@ class ObicoDetectionService:
|
||||
# Say so plainly: the health endpoint is ungated, so "Test
|
||||
# Connection" passes against exactly this configuration and
|
||||
# a raw 401 gives the user nothing to act on (#2733).
|
||||
self._last_error = (
|
||||
#
|
||||
# Obico's auth decorator runs before the handler, so a call
|
||||
# rejected here leaves no trace in the ML API's own log —
|
||||
# which is how #2952 came to be reported as "the loop never
|
||||
# calls the ML API" while it was calling it every 10s.
|
||||
self._no_verdict(
|
||||
printer_id,
|
||||
"Obico ML API rejected the token (401). Set Settings → Failure Detection → "
|
||||
"ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
|
||||
"on the server."
|
||||
"on the server.",
|
||||
)
|
||||
logger.warning("%s (printer %s)", self._last_error, printer_id)
|
||||
return
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as e:
|
||||
detail = str(e) or type(e).__name__
|
||||
self._last_error = f"ML API call failed for printer {printer_id}: {detail}"
|
||||
logger.warning(self._last_error)
|
||||
self._no_verdict(printer_id, f"ML API call failed for printer {printer_id}: {detail}")
|
||||
return
|
||||
|
||||
detections = payload.get("detections", []) if isinstance(payload, dict) else []
|
||||
@@ -328,6 +350,7 @@ class ObicoDetectionService:
|
||||
# A successful capture + ML call clears any transient error from previous
|
||||
# polls (typical case: cold-start RTSP timeout on first frame after startup,
|
||||
# followed by healthy polls that otherwise leave the banner stuck in the UI).
|
||||
self._errors.pop(printer_id, None)
|
||||
self._last_error = None
|
||||
|
||||
# Log every non-safe sample — safe samples would flood history
|
||||
@@ -371,15 +394,39 @@ class ObicoDetectionService:
|
||||
|
||||
Only printers with a running, monitored print have a state entry, so
|
||||
consumers get "show nothing" for idle printers for free.
|
||||
|
||||
Four classes, and the two non-verdict ones matter as much as the rest:
|
||||
|
||||
``error`` the most recent poll produced no verdict. ``error`` carries
|
||||
the reason — a rejected token, an unreachable ML API, a
|
||||
camera that would not yield a frame, an unset External URL.
|
||||
``unknown`` monitored, but no inference has come back yet. The state
|
||||
entry is created when the print is first seen, which is
|
||||
before the first capture, so this is the honest answer for
|
||||
that window.
|
||||
``safe`` / ``warning`` / ``failure``
|
||||
an actual verdict from an actual inference.
|
||||
|
||||
This used to default to ``safe`` whenever no verdict had been recorded,
|
||||
so a printer whose detection had never once succeeded rendered exactly
|
||||
like a healthy one: a green badge reading "Safe" at score 0.000. That is
|
||||
the worst possible failure mode for a safety feature — it asserts the
|
||||
print is being watched precisely when it is not (#2952).
|
||||
"""
|
||||
return {
|
||||
pid: {
|
||||
"class": self._last_class.get(pid, "safe"),
|
||||
result = {}
|
||||
for pid, state in self._states.items():
|
||||
error = self._errors.get(pid)
|
||||
if error:
|
||||
verdict = "error"
|
||||
else:
|
||||
verdict = self._last_class.get(pid) or "unknown"
|
||||
result[pid] = {
|
||||
"class": verdict,
|
||||
"frame_count": state.frame_count,
|
||||
"score": round(state.ewm_mean, 4),
|
||||
"error": error,
|
||||
}
|
||||
for pid, state in self._states.items()
|
||||
}
|
||||
return result
|
||||
|
||||
def get_status(self, sensitivity: str = "medium") -> dict:
|
||||
# Report the thresholds for the configured sensitivity, not a hardcoded
|
||||
|
||||
@@ -79,10 +79,12 @@ class TestObicoPrinterStatus:
|
||||
def clear_detection_state(self):
|
||||
obico_detection_service._states.clear()
|
||||
obico_detection_service._last_class.clear()
|
||||
obico_detection_service._errors.clear()
|
||||
obico_detection_service._last_error = None
|
||||
yield
|
||||
obico_detection_service._states.clear()
|
||||
obico_detection_service._last_class.clear()
|
||||
obico_detection_service._errors.clear()
|
||||
obico_detection_service._last_error = None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -142,3 +144,67 @@ class TestObicoPrinterStatus:
|
||||
data = response.json()
|
||||
for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
|
||||
assert key not in data
|
||||
|
||||
|
||||
class TestObicoPrinterStatusNoVerdict:
|
||||
"""A printer whose detection is not working must not read as monitored (#2952)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_detection_state(self):
|
||||
obico_detection_service._states.clear()
|
||||
obico_detection_service._last_class.clear()
|
||||
obico_detection_service._errors.clear()
|
||||
obico_detection_service._last_error = None
|
||||
yield
|
||||
obico_detection_service._states.clear()
|
||||
obico_detection_service._last_class.clear()
|
||||
obico_detection_service._errors.clear()
|
||||
obico_detection_service._last_error = None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_error_class_and_reason_reach_the_card(self, async_client: AsyncClient):
|
||||
obico_detection_service._states[1] = PrintState()
|
||||
obico_detection_service._errors[1] = "Obico ML API rejected the token (401)."
|
||||
|
||||
response = await async_client.get("/api/v1/obico/printer-status")
|
||||
entry = response.json()["per_printer"]["1"]
|
||||
assert entry["class"] == "error"
|
||||
assert entry["error"] == "Obico ML API rejected the token (401)."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_monitored_but_no_result_yet_is_unknown_not_safe(self, async_client: AsyncClient):
|
||||
obico_detection_service._states[1] = PrintState()
|
||||
|
||||
response = await async_client.get("/api/v1/obico/printer-status")
|
||||
entry = response.json()["per_printer"]["1"]
|
||||
assert entry["class"] == "unknown"
|
||||
assert entry["error"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reason_is_withheld_without_settings_read_but_the_class_is_not(self):
|
||||
"""The reason can name the ML API base or the External URL, so it stays
|
||||
behind settings:read. Whether the print is being watched is not
|
||||
configuration, so a printers:read user still gets the class."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from backend.app.api.routes.obico import get_printer_status
|
||||
|
||||
obico_detection_service._states[1] = PrintState()
|
||||
obico_detection_service._errors[1] = "ML API call failed: http://192.168.8.9:3333 refused"
|
||||
|
||||
user = MagicMock()
|
||||
user.has_permission.return_value = False
|
||||
|
||||
# The route calls _load_settings for the enabled/monitored fields; the
|
||||
# redaction under test is independent of them.
|
||||
loaded = {"enabled": True, "enabled_printers": None}
|
||||
with patch.object(obico_detection_service, "_load_settings", new=AsyncMock(return_value=loaded)):
|
||||
data = await get_printer_status(user=user)
|
||||
entry = data["per_printer"][1]
|
||||
assert entry["class"] == "error"
|
||||
assert entry["error"] is None
|
||||
assert data["last_error"] is None
|
||||
assert "192.168.8.9" not in str(data)
|
||||
|
||||
@@ -796,3 +796,156 @@ class TestCheckPrinterUsesCachedFrameUrl:
|
||||
await svc._check_printer(1, status, settings)
|
||||
|
||||
assert svc._last_error is None
|
||||
|
||||
|
||||
class TestNoVerdictIsNotSafe:
|
||||
"""A printer nothing is looking at must not report itself as safe (#2952).
|
||||
|
||||
``get_per_printer`` used to default to ``"safe"`` whenever no verdict had
|
||||
been recorded, and the state entry is created when the print is first seen —
|
||||
before the first capture, let alone the first inference. So a rejected token,
|
||||
an unreachable ML API, a camera that never yields a frame and an unset
|
||||
External URL all rendered as a green "Safe" badge at score 0.000, identical
|
||||
to a healthy monitored print.
|
||||
|
||||
The reporter of #2952 read exactly that, concluded the detection loop had
|
||||
never started, and spent an evening on the network path — while the loop was
|
||||
calling the ML API every 10s and being turned away with a 401 that Obico's
|
||||
auth decorator rejects before its own request log ever sees it.
|
||||
"""
|
||||
|
||||
SETTINGS = {
|
||||
"enabled": True,
|
||||
"ml_url": "http://obico:3333",
|
||||
"ml_token": "wrong-token",
|
||||
"sensitivity": "medium",
|
||||
"action": "notify",
|
||||
"poll_interval": 10,
|
||||
"enabled_printers": None,
|
||||
"external_url": "http://bambuddy:8000",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _status():
|
||||
return MagicMock(state="RUNNING", task_name="job", subtask_name="")
|
||||
|
||||
@staticmethod
|
||||
def _client(**kwargs):
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(**kwargs)
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_token_reports_error_and_names_the_setting(self):
|
||||
svc = ObicoDetectionService()
|
||||
response = MagicMock(status_code=401)
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.obico_detection.httpx.AsyncClient",
|
||||
return_value=self._client(return_value=response),
|
||||
),
|
||||
patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
|
||||
):
|
||||
await svc._check_printer(1, self._status(), self.SETTINGS)
|
||||
|
||||
entry = svc.get_per_printer()[1]
|
||||
assert entry["class"] == "error"
|
||||
assert "ML API Token" in entry["error"]
|
||||
assert entry["frame_count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreachable_ml_api_reports_error(self):
|
||||
svc = ObicoDetectionService()
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.obico_detection.httpx.AsyncClient",
|
||||
return_value=self._client(side_effect=RuntimeError("connection refused")),
|
||||
),
|
||||
patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
|
||||
):
|
||||
await svc._check_printer(1, self._status(), self.SETTINGS)
|
||||
|
||||
entry = svc.get_per_printer()[1]
|
||||
assert entry["class"] == "error"
|
||||
assert "connection refused" in entry["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_capture_reports_error(self):
|
||||
svc = ObicoDetectionService()
|
||||
with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
|
||||
await svc._check_printer(1, self._status(), self.SETTINGS)
|
||||
|
||||
entry = svc.get_per_printer()[1]
|
||||
assert entry["class"] == "error"
|
||||
assert "capture" in entry["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_external_url_reports_error(self):
|
||||
svc = ObicoDetectionService()
|
||||
settings = {**self.SETTINGS, "external_url": ""}
|
||||
with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)):
|
||||
await svc._check_printer(1, self._status(), settings)
|
||||
|
||||
entry = svc.get_per_printer()[1]
|
||||
assert entry["class"] == "error"
|
||||
assert "External URL" in entry["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_recovered_printer_goes_back_to_a_real_verdict(self):
|
||||
"""The error must not stick once polling works again — otherwise the
|
||||
badge trades one permanent lie for another."""
|
||||
svc = ObicoDetectionService()
|
||||
with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
|
||||
await svc._check_printer(1, self._status(), self.SETTINGS)
|
||||
assert svc.get_per_printer()[1]["class"] == "error"
|
||||
|
||||
ok = MagicMock(status_code=200)
|
||||
ok.json.return_value = {"detections": []}
|
||||
ok.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=self._client(return_value=ok)),
|
||||
patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
|
||||
):
|
||||
await svc._check_printer(1, self._status(), self.SETTINGS)
|
||||
|
||||
entry = svc.get_per_printer()[1]
|
||||
assert entry["class"] == "safe"
|
||||
assert entry["error"] is None
|
||||
assert entry["frame_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_exists_before_the_first_inference_reports_unknown(self):
|
||||
"""The window between "print seen" and "first result" is not safe either."""
|
||||
from backend.app.services.obico_smoothing import PrintState
|
||||
|
||||
svc = ObicoDetectionService()
|
||||
svc._states[1] = PrintState()
|
||||
svc._state_keys[1] = "job"
|
||||
|
||||
entry = svc.get_per_printer()[1]
|
||||
assert entry["class"] == "unknown"
|
||||
assert entry["error"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_is_cleared_when_the_print_ends(self):
|
||||
"""A stale error must not carry into the next print's first poll."""
|
||||
svc = ObicoDetectionService()
|
||||
with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
|
||||
await svc._check_printer(1, self._status(), self.SETTINGS)
|
||||
assert 1 in svc._errors
|
||||
|
||||
idle = MagicMock(state="IDLE", task_name="", subtask_name="")
|
||||
manager = MagicMock()
|
||||
manager.get_all_statuses.return_value = {1: idle}
|
||||
manager.is_connected.return_value = True
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"backend.app.services.printer_manager": MagicMock(printer_manager=manager)},
|
||||
):
|
||||
await svc._poll_once(self.SETTINGS)
|
||||
|
||||
assert svc._errors == {}
|
||||
assert svc._last_class == {}
|
||||
assert svc.get_per_printer() == {}
|
||||
|
||||
@@ -173,3 +173,121 @@ describe('PrintersPage AI detection badge (#1546)', () => {
|
||||
expect(screen.queryByText('Idle')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrintersPage AI detection badge — no verdict is not Safe (#2952)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('printerCardSize');
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockPrinterStatus)),
|
||||
http.get('/api/v1/settings/ui-preferences', () =>
|
||||
HttpResponse.json({
|
||||
ams_humidity_good: 40,
|
||||
ams_humidity_fair: 60,
|
||||
ams_temp_good: 30,
|
||||
ams_temp_fair: 35,
|
||||
})
|
||||
),
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([]))
|
||||
);
|
||||
});
|
||||
|
||||
const withPerPrinter = (entry: Record<string, unknown>) =>
|
||||
server.use(
|
||||
http.get('/api/v1/obico/printer-status', () =>
|
||||
HttpResponse.json({
|
||||
enabled: true,
|
||||
monitored_printers: [1],
|
||||
per_printer: { '1': entry },
|
||||
last_error: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
it('a printer whose detection is failing reads "Not checking", never Safe', async () => {
|
||||
// The reporter's case: the loop is calling the ML API every 10s and being
|
||||
// turned away with a 401. This used to render as a green "Safe" pill at
|
||||
// score 0.000, indistinguishable from a healthy print.
|
||||
withPerPrinter({
|
||||
class: 'error',
|
||||
frame_count: 0,
|
||||
score: 0,
|
||||
error: 'Obico ML API rejected the token (401).',
|
||||
});
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
const badge = await screen.findByText('Not checking');
|
||||
expect(badge.closest('button')).toHaveAttribute(
|
||||
'title',
|
||||
'AI Failure Detection is not checking this print: Obico ML API rejected the token (401). - click for details'
|
||||
);
|
||||
expect(screen.queryByText('Safe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not quote a score the model never produced', async () => {
|
||||
withPerPrinter({ class: 'error', frame_count: 0, score: 0, error: 'Failed to capture snapshot' });
|
||||
|
||||
render(<PrintersPage />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByText('Not checking'));
|
||||
|
||||
expect(await screen.findByText('AI Failure Detection - X1 Carbon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed to capture snapshot')).toBeInTheDocument();
|
||||
// "Score 0.000" next to "Not checking" reads as a measurement rather than
|
||||
// the absence of one, so neither figure is shown.
|
||||
expect(screen.queryByText('0.000')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Frames analyzed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the window before the first result reads "Starting", never Safe', async () => {
|
||||
withPerPrinter({ class: 'unknown', frame_count: 0, score: 0, error: null });
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
expect(await screen.findByText('Starting')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Safe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('an unrecognised class falls back to Starting, not Safe', async () => {
|
||||
// A newer backend class must never be silently absorbed into a green badge.
|
||||
withPerPrinter({ class: 'something-new', frame_count: 5, score: 0.1, error: null });
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
expect(await screen.findByText('Starting')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Safe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still shows Safe when an inference actually said so', async () => {
|
||||
withPerPrinter({ class: 'safe', frame_count: 216, score: 0, error: null });
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
const badge = await screen.findByText('Safe');
|
||||
expect(badge.closest('button')).toHaveAttribute(
|
||||
'title',
|
||||
'AI Failure Detection: Safe (score 0.000) - click for details'
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers this printer's own reason over the service-wide last error", async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/obico/printer-status', () =>
|
||||
HttpResponse.json({
|
||||
enabled: true,
|
||||
monitored_printers: [1],
|
||||
per_printer: { '1': { class: 'error', frame_count: 0, score: 0, error: 'This printer: camera timed out' } },
|
||||
last_error: 'Some other printer: token rejected',
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByText('Not checking'));
|
||||
|
||||
expect(await screen.findByText('This printer: camera timed out')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Some other printer: token rejected')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,11 @@ import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { X, ScanEye, AlertCircle, Settings } from 'lucide-react';
|
||||
import { aiDetectionClass, hasVerdict, type AiDetection } from '../utils/aiDetection';
|
||||
|
||||
interface AiDetectionModalProps {
|
||||
printerName: string;
|
||||
detection?: { class: string; frame_count: number; score: number };
|
||||
detection?: AiDetection;
|
||||
// null = no error, or the viewer lacks settings:read (the backend withholds
|
||||
// error strings from non-settings users because they can embed config URLs)
|
||||
lastError: string | null;
|
||||
@@ -27,9 +28,7 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const cls = detection
|
||||
? (detection.class === 'failure' || detection.class === 'warning' ? detection.class : 'safe')
|
||||
: 'idle';
|
||||
const cls = aiDetectionClass(detection);
|
||||
const statusColor =
|
||||
cls === 'failure'
|
||||
? 'text-status-error'
|
||||
@@ -37,7 +36,14 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
|
||||
? 'text-status-warning'
|
||||
: cls === 'safe'
|
||||
? 'text-status-ok'
|
||||
: 'text-bambu-gray';
|
||||
: cls === 'error'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-bambu-gray';
|
||||
|
||||
// This printer's own reason beats the service-wide one: with several printers
|
||||
// monitored, the global string is whichever failed most recently and may be
|
||||
// about someone else's printer entirely.
|
||||
const reason = detection?.error ?? lastError;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
@@ -66,7 +72,7 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
|
||||
<span className="text-bambu-gray">{t('printers.aiDetection.currentStatus')}</span>
|
||||
<span className={`font-medium ${statusColor}`}>{t(`printers.aiDetection.${cls}`)}</span>
|
||||
</div>
|
||||
{detection ? (
|
||||
{detection && hasVerdict(cls) && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-bambu-gray">{t('printers.aiDetection.score')}</span>
|
||||
@@ -77,19 +83,22 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
|
||||
<span className="text-white font-mono">{detection.frame_count}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-bambu-gray">{t('printers.aiDetection.idleHint')}</p>
|
||||
)}
|
||||
{/* A score of 0.000 next to "Not checking" reads as a reassuring
|
||||
measurement rather than the absence of one, so it is withheld
|
||||
until an inference has actually produced it (#2952). */}
|
||||
{cls === 'error' && <p className="text-bambu-gray">{t('printers.aiDetection.errorHint')}</p>}
|
||||
{!detection && <p className="text-bambu-gray">{t('printers.aiDetection.idleHint')}</p>}
|
||||
</div>
|
||||
|
||||
{lastError && (
|
||||
{reason && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-red-700 dark:text-red-400">
|
||||
{t('printers.aiDetection.lastError')}
|
||||
</div>
|
||||
<p className="text-red-700/80 dark:text-red-300/80 break-words mt-1">{lastError}</p>
|
||||
<p className="text-red-700/80 dark:text-red-300/80 break-words mt-1">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';
|
||||
@@ -81,26 +81,39 @@ export function FailureDetectionSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-save on change (debounced)
|
||||
useEffect(() => {
|
||||
if (!initialized || !settings) return;
|
||||
const changed =
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
if (!initialized || !settings) return false;
|
||||
return (
|
||||
settings.obico_enabled !== enabled ||
|
||||
settings.obico_ml_url !== mlUrl ||
|
||||
(settings.obico_ml_token ?? '') !== mlToken ||
|
||||
settings.obico_sensitivity !== sensitivity ||
|
||||
settings.obico_action !== action ||
|
||||
settings.obico_poll_interval !== pollInterval ||
|
||||
settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters));
|
||||
if (!changed) return;
|
||||
settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters))
|
||||
);
|
||||
}, [settings, initialized, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
|
||||
|
||||
// Auto-save on change (debounced)
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return;
|
||||
const id = setTimeout(() => saveMutation.mutate(), 500);
|
||||
return () => clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]);
|
||||
}, [hasUnsavedChanges, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestResult(null);
|
||||
try {
|
||||
// Flush first, so a green result describes the configuration the
|
||||
// detection loop is actually running with. The loop reads the saved
|
||||
// settings; this form tests what is typed in the boxes. Inside the 500ms
|
||||
// auto-save debounce — or after a save that failed — those are different
|
||||
// values, and "reachable and healthy" for a token the service never
|
||||
// received is the reassuring-green-light problem all over again (#2952).
|
||||
if (hasUnsavedChanges) {
|
||||
await saveMutation.mutateAsync();
|
||||
}
|
||||
const res = await api.testObicoConnection(mlUrl, mlToken);
|
||||
if (res.ok) {
|
||||
// auth_ok is null when the token could not be checked — don't claim it
|
||||
|
||||
@@ -799,6 +799,11 @@ export default {
|
||||
warning: 'Warnung',
|
||||
failure: 'Fehldruck',
|
||||
idle: 'Bereit',
|
||||
error: 'Prüft nicht',
|
||||
unknown: 'Startet',
|
||||
tooltipError: 'KI-Fehlererkennung prüft diesen Druck nicht: {{reason}} - klicken für Details',
|
||||
tooltipUnknown: 'KI-Fehlererkennung: warte auf das erste Ergebnis - klicken für Details',
|
||||
errorHint: 'Dieser Druck wird nicht geprüft. Die Erkennung läuft automatisch weiter, sobald das Problem unten behoben ist.',
|
||||
tooltip: 'KI-Fehlererkennung: {{status}} (Score {{score}}) - klicken für Details',
|
||||
tooltipIdle: 'KI-Fehlererkennung aktiviert - Überwachung startet mit dem nächsten Druck - klicken für Details',
|
||||
modalTitle: 'KI-Fehlererkennung - {{name}}',
|
||||
|
||||
@@ -804,6 +804,11 @@ export default {
|
||||
warning: 'Warning',
|
||||
failure: 'Failure',
|
||||
idle: 'Idle',
|
||||
error: 'Not checking',
|
||||
unknown: 'Starting',
|
||||
tooltipError: 'AI Failure Detection is not checking this print: {{reason}} - click for details',
|
||||
tooltipUnknown: 'AI Failure Detection: waiting for the first result - click for details',
|
||||
errorHint: 'This print is not being checked. Detection resumes automatically once the problem below is fixed.',
|
||||
tooltip: 'AI Failure Detection: {{status}} (score {{score}}) - click for details',
|
||||
tooltipIdle: 'AI Failure Detection enabled - monitoring starts with the next print - click for details',
|
||||
modalTitle: 'AI Failure Detection - {{name}}',
|
||||
|
||||
@@ -799,6 +799,11 @@ export default {
|
||||
warning: 'Advertencia',
|
||||
failure: 'Fallo',
|
||||
idle: 'Inactiva',
|
||||
error: 'Sin comprobar',
|
||||
unknown: 'Iniciando',
|
||||
tooltipError: 'La detección de fallos por IA no está comprobando esta impresión: {{reason}} - haga clic para más detalles',
|
||||
tooltipUnknown: 'Detección de fallos por IA: esperando el primer resultado - haga clic para más detalles',
|
||||
errorHint: 'Esta impresión no se está comprobando. La detección se reanudará automáticamente cuando se solucione el problema indicado abajo.',
|
||||
tooltip: 'Detección de fallos por IA: {{status}} (puntuación {{score}}) - haga clic para más detalles',
|
||||
tooltipIdle: 'Detección de fallos por IA activada - la supervisión comienza con la próxima impresión - haga clic para más detalles',
|
||||
modalTitle: 'Detección de fallos por IA - {{name}}',
|
||||
|
||||
@@ -799,6 +799,11 @@ export default {
|
||||
warning: 'Avertissement',
|
||||
failure: 'Échec',
|
||||
idle: 'Inactif',
|
||||
error: 'Ne vérifie pas',
|
||||
unknown: 'Démarrage',
|
||||
tooltipError: 'La détection d\'échec par IA ne vérifie pas cette impression : {{reason}} - cliquez pour les détails',
|
||||
tooltipUnknown: 'Détection d\'échec par IA : en attente du premier résultat - cliquez pour les détails',
|
||||
errorHint: 'Cette impression n\'est pas vérifiée. La détection reprendra automatiquement une fois le problème ci-dessous résolu.',
|
||||
tooltip: 'Détection d\'échec par IA : {{status}} (score {{score}}) - cliquez pour les détails',
|
||||
tooltipIdle: 'Détection d\'échec par IA activée - la surveillance démarre à la prochaine impression - cliquez pour les détails',
|
||||
modalTitle: 'Détection d\'échec par IA - {{name}}',
|
||||
|
||||
@@ -799,6 +799,11 @@ export default {
|
||||
warning: 'Avviso',
|
||||
failure: 'Guasto',
|
||||
idle: 'Inattiva',
|
||||
error: 'Non controlla',
|
||||
unknown: 'Avvio',
|
||||
tooltipError: 'Il rilevamento guasti con IA non sta controllando questa stampa: {{reason}} - clicca per i dettagli',
|
||||
tooltipUnknown: 'Rilevamento guasti con IA: in attesa del primo risultato - clicca per i dettagli',
|
||||
errorHint: 'Questa stampa non viene controllata. Il rilevamento riprenderà automaticamente una volta risolto il problema indicato sotto.',
|
||||
tooltip: 'Rilevamento guasti con IA: {{status}} (punteggio {{score}}) - clicca per i dettagli',
|
||||
tooltipIdle: 'Rilevamento guasti con IA attivo - il monitoraggio inizia con la prossima stampa - clicca per i dettagli',
|
||||
modalTitle: 'Rilevamento guasti con IA - {{name}}',
|
||||
|
||||
@@ -798,6 +798,11 @@ export default {
|
||||
warning: '警告',
|
||||
failure: '失敗',
|
||||
idle: '待機中',
|
||||
error: '確認していません',
|
||||
unknown: '開始中',
|
||||
tooltipError: 'AI 失敗検出はこの印刷を確認していません: {{reason}} - クリックで詳細を表示',
|
||||
tooltipUnknown: 'AI 失敗検出: 最初の結果を待機中 - クリックで詳細を表示',
|
||||
errorHint: 'この印刷は確認されていません。下記の問題が解決されると、検出は自動的に再開されます。',
|
||||
tooltip: 'AI 失敗検出: {{status}}(スコア {{score}})- クリックで詳細を表示',
|
||||
tooltipIdle: 'AI 失敗検出が有効 - 次の印刷から監視を開始します - クリックで詳細を表示',
|
||||
modalTitle: 'AI 失敗検出 - {{name}}',
|
||||
|
||||
@@ -754,6 +754,11 @@ export default {
|
||||
warning: '경고',
|
||||
failure: '실패',
|
||||
idle: '대기 중',
|
||||
error: '확인 안 함',
|
||||
unknown: '시작 중',
|
||||
tooltipError: 'AI 실패 감지가 이 인쇄를 확인하고 있지 않습니다: {{reason}} - 클릭하여 자세히 보기',
|
||||
tooltipUnknown: 'AI 실패 감지: 첫 번째 결과를 기다리는 중 - 클릭하여 자세히 보기',
|
||||
errorHint: '이 인쇄는 확인되고 있지 않습니다. 아래 문제가 해결되면 감지가 자동으로 재개됩니다.',
|
||||
tooltip: 'AI 실패 감지: {{status}} (점수 {{score}}) - 클릭하여 자세히 보기',
|
||||
tooltipIdle: 'AI 실패 감지 활성화됨 - 다음 인쇄부터 모니터링을 시작합니다 - 클릭하여 자세히 보기',
|
||||
modalTitle: 'AI 실패 감지 - {{name}}',
|
||||
|
||||
@@ -799,6 +799,11 @@ export default {
|
||||
warning: 'Aviso',
|
||||
failure: 'Falha',
|
||||
idle: 'Ocioso',
|
||||
error: 'Não verificando',
|
||||
unknown: 'Iniciando',
|
||||
tooltipError: 'A Detecção de Falhas por IA não está verificando esta impressão: {{reason}} - clique para detalhes',
|
||||
tooltipUnknown: 'Detecção de Falhas por IA: aguardando o primeiro resultado - clique para detalhes',
|
||||
errorHint: 'Esta impressão não está sendo verificada. A detecção será retomada automaticamente assim que o problema abaixo for resolvido.',
|
||||
tooltip: 'Detecção de Falhas por IA: {{status}} (pontuação {{score}}) - clique para detalhes',
|
||||
tooltipIdle: 'Detecção de Falhas por IA ativada - o monitoramento começa na próxima impressão - clique para detalhes',
|
||||
modalTitle: 'Detecção de Falhas por IA - {{name}}',
|
||||
|
||||
@@ -759,6 +759,11 @@ export default {
|
||||
warning: "Предупреждение",
|
||||
failure: "Сбой",
|
||||
idle: "Ожидает",
|
||||
error: "Не проверяется",
|
||||
unknown: "Запуск",
|
||||
tooltipError: "Обнаружение сбоев с помощью ИИ не проверяет эту печать: {{reason}} - нажмите для подробностей",
|
||||
tooltipUnknown: "Обнаружение сбоев с помощью ИИ: ожидание первого результата - нажмите для подробностей",
|
||||
errorHint: "Эта печать не проверяется. Обнаружение возобновится автоматически после устранения проблемы, указанной ниже.",
|
||||
tooltip: "Обнаружение сбоев с помощью ИИ: {{status}} (оценка {{score}}) - нажмите для подробностей",
|
||||
tooltipIdle: "Обнаружение сбоев с помощью ИИ включено - мониторинг начнётся со следующей печати - нажмите для подробностей",
|
||||
modalTitle: "Обнаружение сбоев с помощью ИИ - {{name}}",
|
||||
|
||||
@@ -799,6 +799,11 @@ export default {
|
||||
warning: 'Uyarı',
|
||||
failure: 'Başarısızlık',
|
||||
idle: 'Boşta',
|
||||
error: 'Kontrol etmiyor',
|
||||
unknown: 'Başlatılıyor',
|
||||
tooltipError: 'AI Başarısızlık Algılama bu baskıyı kontrol etmiyor: {{reason}} - ayrıntılar için tıklayın',
|
||||
tooltipUnknown: 'AI Başarısızlık Algılama: ilk sonuç bekleniyor - ayrıntılar için tıklayın',
|
||||
errorHint: 'Bu baskı kontrol edilmiyor. Aşağıdaki sorun giderildiğinde algılama otomatik olarak devam eder.',
|
||||
tooltip: 'AI Başarısızlık Algılama: {{status}} (puan {{score}}) - ayrıntılar için tıklayın',
|
||||
tooltipIdle: 'AI Başarısızlık Algılama etkin - izleme bir sonraki baskıyla başlar - ayrıntılar için tıklayın',
|
||||
modalTitle: 'AI Başarısızlık Algılama - {{name}}',
|
||||
|
||||
@@ -803,6 +803,11 @@ export default {
|
||||
warning: "Попередження",
|
||||
failure: "Помилка",
|
||||
idle: "Очікування",
|
||||
error: "Не перевіряє",
|
||||
unknown: "Запуск",
|
||||
tooltipError: "Виявлення помилок за допомогою ШІ не перевіряє цей друк: {{reason}} — натисніть, щоб переглянути деталі",
|
||||
tooltipUnknown: "Виявлення помилок за допомогою ШІ: очікування першого результату — натисніть, щоб переглянути деталі",
|
||||
errorHint: "Цей друк не перевіряється. Виявлення відновиться автоматично після усунення проблеми, наведеної нижче.",
|
||||
tooltip: "Виявлення помилок за допомогою ШІ: {{status}} (оцінка {{score}}) — натисніть, щоб переглянути деталі",
|
||||
tooltipIdle: "Виявлення помилок за допомогою ШІ ввімкнено — моніторинг почнеться з наступним друком — натисніть, щоб переглянути деталі",
|
||||
modalTitle: "Виявлення помилок за допомогою ШІ — {{name}}",
|
||||
|
||||
@@ -799,6 +799,11 @@ export default {
|
||||
warning: '警告',
|
||||
failure: '失败',
|
||||
idle: '空闲',
|
||||
error: '未检测',
|
||||
unknown: '启动中',
|
||||
tooltipError: 'AI 故障检测未检测此次打印:{{reason}} - 点击查看详情',
|
||||
tooltipUnknown: 'AI 故障检测:正在等待首个结果 - 点击查看详情',
|
||||
errorHint: '本次打印未被检测。下方问题解决后,检测将自动恢复。',
|
||||
tooltip: 'AI 故障检测:{{status}}(评分 {{score}})- 点击查看详情',
|
||||
tooltipIdle: 'AI 故障检测已启用 - 下次打印时开始监控 - 点击查看详情',
|
||||
modalTitle: 'AI 故障检测 - {{name}}',
|
||||
|
||||
@@ -799,6 +799,11 @@ export default {
|
||||
warning: '警告',
|
||||
failure: '失敗',
|
||||
idle: '空閒',
|
||||
error: '未檢測',
|
||||
unknown: '啟動中',
|
||||
tooltipError: 'AI 故障檢測未檢測此次列印:{{reason}} - 點擊查看詳情',
|
||||
tooltipUnknown: 'AI 故障檢測:正在等待首個結果 - 點擊查看詳情',
|
||||
errorHint: '本次列印未被檢測。下方問題解決後,檢測將自動恢復。',
|
||||
tooltip: 'AI 故障檢測:{{status}}(評分 {{score}})- 點擊查看詳情',
|
||||
tooltipIdle: 'AI 故障檢測已啟用 - 下次列印時開始監控 - 點擊查看詳情',
|
||||
modalTitle: 'AI 故障檢測 - {{name}}',
|
||||
|
||||
@@ -157,6 +157,7 @@ import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
|
||||
import { MQTTDebugModal } from '../components/MQTTDebugModal';
|
||||
import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
|
||||
import { AiDetectionModal } from '../components/AiDetectionModal';
|
||||
import { aiDetectionClass, type AiDetection } from '../utils/aiDetection';
|
||||
import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
|
||||
import { PrinterHASensorRow } from '../components/PrinterHASensorRow';
|
||||
import { AMSHistoryModal } from '../components/AMSHistoryModal';
|
||||
@@ -2154,7 +2155,7 @@ function PrinterCard({
|
||||
chamberTempPresets?: readonly [number, number, number];
|
||||
fanSpeedPresets?: readonly [number, number, number];
|
||||
aiDetectionEnabled?: boolean;
|
||||
aiDetection?: { class: string; frame_count: number; score: number };
|
||||
aiDetection?: AiDetection;
|
||||
aiLastError?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -3991,9 +3992,7 @@ function PrinterCard({
|
||||
is enabled for this printer, like the other health badges. Gray
|
||||
"Idle" outside a monitored print, class-colored during one. */}
|
||||
{aiDetectionEnabled && (() => {
|
||||
const cls = aiDetection
|
||||
? (aiDetection.class === 'failure' || aiDetection.class === 'warning' ? aiDetection.class : 'safe')
|
||||
: 'idle';
|
||||
const cls = aiDetectionClass(aiDetection);
|
||||
const colorClass =
|
||||
cls === 'failure'
|
||||
? 'bg-status-error/20 text-status-error'
|
||||
@@ -4001,21 +4000,33 @@ function PrinterCard({
|
||||
? 'bg-status-warning/20 text-status-warning'
|
||||
: cls === 'safe'
|
||||
? 'bg-status-ok/20 text-status-ok'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray';
|
||||
return (
|
||||
<button
|
||||
onClick={() => setShowAiModal(true)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${colorClass}`}
|
||||
title={
|
||||
aiDetection
|
||||
: cls === 'error'
|
||||
? 'bg-amber-500/20 text-amber-600 dark:text-amber-400'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray';
|
||||
// 'error' and 'unknown' have no score to quote — saying
|
||||
// "Safe (0.000)" for a print nothing is looking at is the
|
||||
// whole of #2952.
|
||||
const title =
|
||||
cls === 'error'
|
||||
? t('printers.aiDetection.tooltipError', {
|
||||
reason: aiDetection?.error ?? t('printers.aiDetection.error'),
|
||||
})
|
||||
: cls === 'unknown'
|
||||
? t('printers.aiDetection.tooltipUnknown')
|
||||
: aiDetection
|
||||
? t('printers.aiDetection.tooltip', {
|
||||
status: t(`printers.aiDetection.${cls}`),
|
||||
score: aiDetection.score.toFixed(3),
|
||||
})
|
||||
: t('printers.aiDetection.tooltipIdle')
|
||||
}
|
||||
: t('printers.aiDetection.tooltipIdle');
|
||||
const Icon = cls === 'error' ? EyeOff : ScanEye;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setShowAiModal(true)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${colorClass}`}
|
||||
title={title}
|
||||
>
|
||||
<ScanEye className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)]" />
|
||||
<Icon className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)]" />
|
||||
{t(`printers.aiDetection.${cls}`)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Shared shape + class mapping for the Obico AI failure-detection surfaces
|
||||
// (the printer card badge and the detail modal), so the two cannot disagree
|
||||
// about what a given backend class means.
|
||||
|
||||
export type AiDetectionClass = 'failure' | 'warning' | 'safe' | 'error' | 'unknown' | 'idle';
|
||||
|
||||
export interface AiDetection {
|
||||
class: string;
|
||||
frame_count: number;
|
||||
score: number;
|
||||
// Why the most recent poll produced no verdict. null when the last poll
|
||||
// succeeded, and also when the viewer lacks settings:read — the backend
|
||||
// withholds the reason (it can name configured URLs) but still sends the
|
||||
// 'error' class, because "your print is not being watched" is not
|
||||
// configuration.
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical display class for a printer's detection state.
|
||||
*
|
||||
* `undefined` means the printer has no monitored print right now -> 'idle'.
|
||||
*
|
||||
* Anything unrecognised falls back to 'unknown', deliberately NOT to 'safe'.
|
||||
* Collapsing every non-failure/warning class into a green "Safe" badge is
|
||||
* exactly what made a printer whose detection had never once succeeded look
|
||||
* identical to a healthy one (#2952).
|
||||
*/
|
||||
export function aiDetectionClass(detection?: AiDetection): AiDetectionClass {
|
||||
if (!detection) return 'idle';
|
||||
switch (detection.class) {
|
||||
case 'failure':
|
||||
case 'warning':
|
||||
case 'safe':
|
||||
case 'error':
|
||||
return detection.class;
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the class represents an actual verdict, so score/frames mean something. */
|
||||
export function hasVerdict(cls: AiDetectionClass): boolean {
|
||||
return cls === 'failure' || cls === 'warning' || cls === 'safe';
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DT_cxC1r.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CzJYDbxh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DjndScv6.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user