fix(camera): plate-detection UI now uses the external camera when configured (#1359)

Reporter @Andlar94 hit a permanent "Build plate not empty" on every print
  start on an A1 with an external RTSP camera. The runtime auto-check at
  main.py:1819 called check_plate_empty with use_external=external_camera_enabled,
  but the manual UI routes (camera.py) declared use_external: bool = False
  and the frontend client always sent use_external=false. So calibration
  captured a built-in frame and stamped it as the reference; the runtime
  check captured an external frame and diffed it against that reference --
  a permanent mismatch.

  Centralise the default on the backend: both routes now take bool | None,
  deriving the default from the printer's external_camera_enabled +
  external_camera_url + external_camera_type. The frontend client stops
  sending the flag unless the caller explicitly sets it, so the existing
  UI call sites immediately benefit and any future caller gets the right
  camera automatically. Explicit overrides still win.

  Adds 4 regression tests pinning the new default for both the
  external-enabled and external-disabled cases, plus the explicit override
  path so a future "always built-in" caller stays supported.
This commit is contained in:
maziggy
2026-05-15 14:32:18 +02:00
parent ae29a7dcd3
commit 29379e3be7
6 changed files with 166 additions and 8 deletions
+2
View File
@@ -18,6 +18,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.
### Fixed
- **Plate-detection calibration captured the wrong camera when an external camera was configured** ([#1359](https://github.com/maziggy/bambuddy/issues/1359), reported by @Andlar94) — On the reporter's A1 with an external RTSP / go2rtc camera enabled, every print start raised "Build plate not empty" no matter how perfectly they calibrated. **Root cause:** the runtime auto-check at print start in [`backend/app/main.py:1819`](backend/app/main.py) called `check_plate_empty(..., use_external=printer.external_camera_enabled, ...)` — honouring the external camera setting. The manual UI check + calibration routes in [`backend/app/api/routes/camera.py`](backend/app/api/routes/camera.py) declared `use_external: bool = False`, and the frontend client at [`frontend/src/api/client.ts`](frontend/src/api/client.ts) always sent `use_external=false` explicitly (the UI call sites in [`PrintersPage.tsx`](frontend/src/pages/PrintersPage.tsx) never passed `useExternal`). So calibration captured a frame from the **built-in** chamber camera and saved it as the reference; the runtime auto-check captured a frame from the **external** camera and diffed it against that built-in reference — a permanent difference well above any sane threshold, hence "not empty" on every print. **Fix:** the two routes now use `use_external: bool | None = None`, and after the printer row is loaded they derive the default as `bool(printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type)` — identical to the runtime path's logic and the service-layer gate at [`plate_detection.py:605`](backend/app/services/plate_detection.py). Centralising the default on the backend means any current or future caller automatically gets the right camera without having to remember the flag. The frontend client now only forwards `use_external` when the caller explicitly sets it (default omitted → backend decides), so the existing UI buttons immediately benefit. Power-user override path stays open: passing `?use_external=false` on a printer with an external camera still wins, so anyone who deliberately wants a built-in-camera reference can still get one. **Regression tests** in [`backend/tests/integration/test_camera_api.py`](backend/tests/integration/test_camera_api.py): `test_check_plate_defaults_use_external_when_external_camera_enabled` and `test_calibrate_plate_defaults_use_external_when_external_camera_enabled` pin the new default for a printer with external camera + URL + type set; `test_check_plate_defaults_use_external_false_when_external_camera_disabled` pins the built-in default for the no-external-camera case (the common path stays untouched); `test_calibrate_plate_explicit_use_external_false_overrides_default` pins the explicit-override escape hatch. All 11 plate-tagged camera integration tests pass; ruff clean; frontend build clean.
- **API Keys page now exposes a narrowly-scoped "Update electricity price" toggle so the Home Assistant dynamic-tariff integration actually works** ([#1356](https://github.com/maziggy/bambuddy/issues/1356), reported by @maziggy) — The reporter followed the [Energy Tracking wiki](https://wiki.bambuddy.cool/features/api-keys/) page literally — "create a key with **Write Settings** permission, then PATCH `/api/v1/settings` with `{energy_cost_per_kwh: ...}`" — and hit `{"detail":"API keys cannot be used for administrative operations"}`. Triage showed three independent drifts: (1) the wiki listed nine fictional permissions ("Read Printers / Write Settings / Admin / …") but the actual UI in [`SettingsPage.tsx:3683-3744`](frontend/src/pages/SettingsPage.tsx) only ever exposed **four** toggles (Read Status, Manage Queue, Control Printer, Allow Cloud Access). There was no Write Settings toggle to tick. (2) Even if the UI had exposed it, the backend hard-denies `Permission.SETTINGS_UPDATE` for every API key via `_APIKEY_DENIED_PERMISSIONS` in [`backend/app/core/auth.py`](backend/app/core/auth.py) — intentional protection because `PATCH /settings` can rewrite SMTP/LDAP/MQTT credentials and the HA access token, which would silently widen attack surface beyond what any documented use case needs. (3) So the wiki had been promising a workflow that was never deliverable. **Fix:** introduce a narrowly-scoped door for exactly the documented use case rather than relaxing the deny list. New column `can_update_energy_cost BOOLEAN DEFAULT FALSE` on `api_keys` ([`backend/app/models/api_key.py`](backend/app/models/api_key.py)) with idempotent migration in [`backend/app/core/database.py`](backend/app/core/database.py) — defaults FALSE so existing keys never silently gain settings-write capability on upgrade. New endpoint `POST /api/v1/settings/electricity-price` in [`backend/app/api/routes/settings.py`](backend/app/api/routes/settings.py) accepts `{"energy_cost_per_kwh": <float ≥ 0>}` — the field name matches what the wiki already documented so the HA `rest_command` example needs only a URL+method change, not a payload change. New custom dependency `require_energy_cost_update()` in [`backend/app/core/auth.py`](backend/app/core/auth.py) bypasses the `_APIKEY_DENIED_PERMISSIONS` check **for this one route** for API keys with `can_update_energy_cost=True`; JWT users still go through the standard `SETTINGS_UPDATE` permission check; auth-disabled deployments allow it (matches other settings routes). Crucially, the general `PATCH /settings` route remains denied for API keys — flipping the narrow flag does NOT widen general settings-write access (regression test pins this). Schema/route wiring in [`backend/app/schemas/api_key.py`](backend/app/schemas/api_key.py) + [`backend/app/api/routes/api_keys.py`](backend/app/api/routes/api_keys.py) accepts and returns the new field on create/update/list. **Frontend:** fifth toggle "Update electricity price" added to the create-API-key card in [`SettingsPage.tsx`](frontend/src/pages/SettingsPage.tsx) with an amber "Energy" badge on existing keys that have it set; `APIKey` / `APIKeyCreate` / `APIKeyUpdate` types in [`api/client.ts`](frontend/src/api/client.ts) gained the new field; 16 new i18n keys (`updateEnergyCost`, `updateEnergyCostDescription`, `energyCostBadge`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Wiki rewrites:** [`features/api-keys.md`](https://wiki.bambuddy.cool/features/api-keys/) — replaced the fictional 9-row permissions table with the actual 5 toggles plus an info box explaining why no general Write Settings / Admin exists. [`features/energy.md`](https://wiki.bambuddy.cool/features/energy/) — Home Assistant section now points at `POST /api/v1/settings/electricity-price`, instructs users to tick the new permission, and adds a deprecation warning for users who built the integration from the old (broken) `PATCH /settings` example. **Tests:** [`backend/tests/integration/test_settings_electricity_price.py`](backend/tests/integration/test_settings_electricity_price.py) — 8 tests covering create-with-flag, default-off, API-key-with-flag updates persist, API-key-without-flag → 403, JWT admin user with SETTINGS_UPDATE allowed, anon → 401, negative price → 422 (Pydantic `ge=0`), and the critical regression test `test_patch_settings_still_denied_with_energy_flag` that pins the narrow-flag-doesn't-widen-PATCH contract. [`frontend/src/__tests__/pages/SettingsPage.test.tsx`](frontend/src/__tests__/pages/SettingsPage.test.tsx) — 2 new tests: Energy badge renders for keys with the flag, the toggle's value flows through to the POST body when the box is ticked. All 8 new backend tests + 32/32 SettingsPage tests pass; ruff clean; i18n parity passes; frontend build clean.
- **Layer timelapse now starts for queue/VP-dispatched prints** ([#1353](https://github.com/maziggy/bambuddy/issues/1353), reported by @Andlar94) — Reporter's external camera + go2rtc setup was configured correctly (Obico was happily polling the snapshot URL for ML plate detection) but no MP4 was ever produced. Logs showed `[LAYER-TL] Stitching layer timelapse for printer 1` after each print yet no frames were ever captured and no `[LAYER-TL] Attaching timelapse...` follow-up appeared. Root cause: `layer_timelapse.start_session()` was only called from the two **new-archive** paths in `on_print_start` (`backend/app/main.py:2510` fallback path and `2600` regular new-archive). The **expected-archive** branch at `main.py:1981-2052` — where every reprint and every queue/VP-dispatched print lands — updated the existing archive's status to `printing` but never started a timelapse session. So `_background_layer_timelapse` ran at print-complete time, called `tl_complete(printer_id)`, found no active session in `_active_sessions`, silently returned `None`, and the wrapper at `main.py:3917` produced no log message for the no-session case. Every print that came through the queue (or any reprint) silently lost its timelapse. **Fix:** mirror the same `if printer.external_camera_enabled and printer.external_camera_url: start_session(...)` call in the expected-archive branch right after `_active_prints` registration. The two pre-existing paths are untouched. **Help-text correction:** the snapshot URL field's tooltip previously read *"Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection"* — which is technically true but read as if filling in the URL was sufficient to enable those features. Reworded across all 8 locales to *"Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active"* so admins know they still need to enable plate detection per-printer (separate toggle) and that timelapse only fires while a print is running. **Regression tests** in [`backend/tests/unit/test_layer_timelapse_expected_archive.py`](backend/tests/unit/test_layer_timelapse_expected_archive.py): `test_expected_archive_path_starts_timelapse_when_external_camera_enabled` exercises the full `on_print_start` flow with a registered expected print + `external_camera_enabled=True` and asserts `start_session` is called with the expected-print archive_id (not a freshly created one); `test_expected_archive_path_skips_timelapse_when_external_camera_disabled` keeps the existing gate in place so we don't try to capture from a None URL. 2 new tests pass; ruff clean; frontend i18n parity passes; bundle builds.
+21 -4
View File
@@ -1001,7 +1001,7 @@ async def test_external_camera(
async def check_plate_empty(
printer_id: int,
plate_type: str | None = None,
use_external: bool = False,
use_external: bool | None = None,
include_debug_image: bool = False,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
@@ -1016,7 +1016,11 @@ async def check_plate_empty(
Args:
printer_id: Printer ID
plate_type: Type of build plate (e.g., "High Temp Plate") for calibration lookup
use_external: If True, prefer external camera over built-in
use_external: If True, prefer external camera over built-in. When omitted
(None), defaults to the printer's external_camera_enabled setting —
mirroring the runtime auto-check at print start (main.py). Without
this default the UI's manual check would always use the built-in
camera, mismatching the reference saved during calibration (#1359).
include_debug_image: If True, return URL to annotated debug image
Returns:
@@ -1037,6 +1041,11 @@ async def check_plate_empty(
# Check printer exists first (before OpenCV check)
printer = await get_printer_or_404(printer_id, db)
if use_external is None:
use_external = bool(
printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type
)
if not is_plate_detection_available():
raise HTTPException(
status_code=503,
@@ -1111,7 +1120,7 @@ async def check_plate_empty(
async def calibrate_plate_detection(
printer_id: int,
label: str | None = None,
use_external: bool = False,
use_external: bool | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
@@ -1128,7 +1137,10 @@ async def calibrate_plate_detection(
Args:
printer_id: Printer ID
label: Optional label for this reference (e.g., "High Temp Plate", "Wham Bam")
use_external: If True, prefer external camera over built-in
use_external: If True, prefer external camera over built-in. When omitted
(None), defaults to the printer's external_camera_enabled setting so
calibration captures from the same source the runtime auto-check
uses at print start (#1359).
Returns:
Dict with:
@@ -1145,6 +1157,11 @@ async def calibrate_plate_detection(
# Check printer exists first (before OpenCV check)
printer = await get_printer_or_404(printer_id, db)
if use_external is None:
use_external = bool(
printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type
)
if not is_plate_detection_available():
raise HTTPException(
status_code=503,
@@ -459,6 +459,138 @@ class TestCameraAPI:
assert result["success"] is True
assert "index" in result
# ------------------------------------------------------------------
# Regression: #1359 — the manual UI check/calibrate routes must derive
# use_external from the printer's external_camera_enabled setting when
# the caller omits the flag. Otherwise the UI calibrates against the
# built-in camera while the runtime auto-check at print start uses the
# external one, producing a permanent "build plate not empty".
# ------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.integration
async def test_check_plate_defaults_use_external_when_external_camera_enabled(
self, async_client: AsyncClient, printer_factory
):
"""Omitting use_external on a printer with external camera enabled
must call the service with use_external=True."""
printer = await printer_factory(
external_camera_enabled=True,
external_camera_url="http://192.168.1.50/mjpeg",
external_camera_type="mjpeg",
)
mock_result = MagicMock()
mock_result.to_dict.return_value = {
"is_empty": True,
"confidence": 0.95,
"difference_percent": 0.5,
"message": "Plate appears empty",
"has_debug_image": False,
"needs_calibration": False,
}
mock_result.debug_image = None
mock_detector = MagicMock()
mock_detector.get_calibration_count.return_value = 0
mock_detector.MAX_REFERENCES = 5
with (
patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
):
mock_check.return_value = mock_result
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
assert response.status_code == 200
assert mock_check.await_args.kwargs["use_external"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_check_plate_defaults_use_external_false_when_external_camera_disabled(
self, async_client: AsyncClient, printer_factory
):
"""Omitting use_external on a printer without an external camera
must call the service with use_external=False (built-in)."""
printer = await printer_factory() # external_camera_enabled defaults to False
mock_result = MagicMock()
mock_result.to_dict.return_value = {
"is_empty": True,
"confidence": 0.95,
"difference_percent": 0.5,
"message": "Plate appears empty",
"has_debug_image": False,
"needs_calibration": False,
}
mock_result.debug_image = None
mock_detector = MagicMock()
mock_detector.get_calibration_count.return_value = 0
mock_detector.MAX_REFERENCES = 5
with (
patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
):
mock_check.return_value = mock_result
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
assert response.status_code == 200
assert mock_check.await_args.kwargs["use_external"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_calibrate_plate_defaults_use_external_when_external_camera_enabled(
self, async_client: AsyncClient, printer_factory
):
"""Calibrating with use_external omitted on an external-camera-enabled
printer captures the reference from the external camera — matching
what the runtime check at print start will compare against (#1359)."""
printer = await printer_factory(
external_camera_enabled=True,
external_camera_url="http://192.168.1.50/mjpeg",
external_camera_type="mjpeg",
)
with (
patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
):
mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
assert response.status_code == 200
assert mock_calibrate.await_args.kwargs["use_external"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_calibrate_plate_explicit_use_external_false_overrides_default(
self, async_client: AsyncClient, printer_factory
):
"""An explicit use_external=false from the caller still wins even
when the printer has an external camera configured, so power users
can force a built-in-camera reference if they ever need to."""
printer = await printer_factory(
external_camera_enabled=True,
external_camera_url="http://192.168.1.50/mjpeg",
external_camera_type="mjpeg",
)
with (
patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
):
mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
response = await async_client.post(
f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate?use_external=false"
)
assert response.status_code == 200
assert mock_calibrate.await_args.kwargs["use_external"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient):
+9 -2
View File
@@ -4914,7 +4914,12 @@ export const api = {
// Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
const params = new URLSearchParams();
params.set('use_external', String(options?.useExternal ?? false));
// Only forward use_external when the caller explicitly sets it. Omitted →
// backend derives the default from the printer's external_camera_enabled
// setting so calibration and runtime checks use the same camera (#1359).
if (options?.useExternal !== undefined) {
params.set('use_external', String(options.useExternal));
}
params.set('include_debug_image', String(options?.includeDebugImage ?? false));
return request<PlateDetectionResult>(
`/printers/${printerId}/camera/check-plate?${params.toString()}`
@@ -4928,7 +4933,9 @@ export const api = {
calibratePlateDetection: (printerId: number, options?: { label?: string; useExternal?: boolean }) => {
const params = new URLSearchParams();
if (options?.label) params.set('label', options.label);
params.set('use_external', String(options?.useExternal ?? false));
if (options?.useExternal !== undefined) {
params.set('use_external', String(options.useExternal));
}
return request<CalibrationResult & { index: number }>(
`/printers/${printerId}/camera/plate-detection/calibrate?${params.toString()}`,
{ method: 'POST' }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-BlvVqj3j.js"></script>
<script type="module" crossorigin src="/assets/index-lshkWq_8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
</head>
<body>