diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ce3b84cd..eef6eb310 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b2] - Unreleased
### Added
+- **Cam Wall on its own URL, and on a TV that isn't logged in (#2531, reporter @cadtoolbox)** — The Cam Wall was reachable exactly one way: click the Cam wall button on the Printers page. It had no URL, so you couldn't bookmark it, link to it, or point a wall-mounted screen at it. It now lives at **`/camwall`**, and a button next to the Cards / Cam wall toggle opens it there. Signed in, that page is the same wall you already know — tiles clickable, settings popover working, the knobs shared with the Printers page through the same localStorage keys, so a change in one follows you to the other. **The TV case is the hard half.** A screen in a workshop has no login session, and a wall tile needs two things a camera token could not previously fetch: the list of printers, and each one's status for the state badge. Both sit behind `PRINTERS_READ`, so a kiosk got a 401 and an empty wall. The obvious fix — let the existing `camera_stream` token through to `GET /printers` — is the wrong one: that response carries every printer's `serial_number` and `ip_address` even in its non-secret shape, and a URL pinned to a lobby TV lives in the browser history, in the kiosk's config file, and on the screen itself. So the Cam Wall gets a **purpose-built read-only feed** at `GET /api/v1/camwall/printers` that serves only what a tile draws: id, name, camera rotation, connected, state, progress, layers, remaining time, HMS codes. No serial. No IP. No access code. **And no filename** — a token wall renders the compact overlay, so the field simply isn't served rather than being served and then hidden client-side; the part on the bed is never named to a room anyone can walk into. **A second scope, not a wider one.** The feed is gated on a new `camwall` token scope alongside `camera_stream`. A Cam Wall token reaches the video *and* the tile metadata; a camera-stream token reaches the video and is refused by the feed. That matters because `camera_stream` tokens are already in the wild, minted by people who agreed to hand out a picture — shipping this must not retroactively grant them the ability to enumerate a fleet by name. Pick the scope when you create the token in **Settings → API Keys → Camera API Tokens**; the create dialog then hands you the finished kiosk URL, fully assembled, so nobody has to build it from the docs. **What a token wall gives up.** No settings popover and no click-through: a TV has nobody standing at it, and click-through would open a page the token cannot authenticate. The controls are not merely hidden — they aren't rendered, so a kiosk carries no focusable control it cannot act on. The overlay is capped at `compact` even if the URL asks for `full`. The screen can still be tuned from the URL: `?maxLive=9&interval=10&status=compact`, all clamped to the same ranges the popover enforces. **Statuses are polled, not pushed** — the page renders outside the app layout and its WebSocket provider, and a kiosk token cannot mint a WS ticket anyway; a wall is watched, not operated, so a 5-second cadence costs nothing. **Revoking the token cuts the display off** on its next request. **Tests.** 11 backend cases: no token / garbage token / revoked token all rejected; a `camera_stream` token refused by the feed (the assertion the separate scope exists for); a `camwall` token accepted; the payload's key set pinned so a future field can't quietly add a serial, an IP or a filename; a Cam Wall token passes the camera-stream gate so its own tiles fill; a camera-stream token still passes its own gate (regression guard on #1108); and the scope allowlist pinned so adding a third scope has to be a deliberate act. 7 frontend cases covering the kiosk feed being called with the URL token, the token reaching the `` URLs, no settings popover, inert tiles, `?status=full` refused, the expired-token message, and — the negative — a tokenless visit never touching the kiosk endpoint. **Scope.** New endpoint, new token scope, new route. No DB migration, no new permission, no change to the in-page wall.
- **Live print progress for Virtual Printers in Bambu Studio / OrcaSlicer (#1887, reporter @YozenPL)** — Connect the slicer to a server-mode VP with a target printer bound and the Device tab shows the printer's AMS, temperatures and camera, but the print itself reads as a name and nothing else: no stage, no percentage, no layer count, no time remaining. The data was never missing — the bridge has the target's real `push_status` cached, `mc_percent` and all — Bambuddy was deliberately overwriting it with zeros. **Why it was zeroed.** #1558, the exact inverse complaint: a queue-mode VP that passed the live values through was read by Bambu Studio as *busy*, and the Send button went away for as long as the printer printed, which defeats the entire purpose of queueing. **Why you cannot simply have both.** Both slicers gate the Device-tab progress panel and the Send button on one and the same predicate — `MachineObject::is_in_printing()`, true when `gcode_state` is RUNNING / PAUSE / SLICING / PREPARE. `StatusPanel::update_subtask()` draws the progress bar on it; `SelectMachineDialog::update_show_status()` disables Send on it. Report the printer's state honestly and you get progress at the cost of Send; zero it and you get Send at the cost of progress. There is no field-level trick, because it is one boolean. **The fix.** There is exactly one state in the gap: `FINISH`. StatusPanel renders the full progress panel for it (`is_in_printing() || print_status == "FINISH"`), SelectMachineDialog does not consider it busy. The VP already parks there after every upload — that is the #1280 / #1658 send-modal handshake — which is precisely why the reporter saw a file name and no numbers: the slicer was already drawing the widget, and we were feeding it zeros. So while the target printer is printing and the VP has no upload of its own in flight, the report now holds `gcode_state=FINISH` and passes the real `mc_print_stage`, `mc_percent`, `mc_remaining_time`, `stg`, `stg_cur`, `layer_num` and `total_layer_num` through underneath it, at the existing 1 Hz push. Send stays enabled in every mode and #1558 does not come back. **What it costs.** The slicer's Pause / Resume / Stop buttons stay greyed for a server-mode VP, since it now reports a finished job rather than a running one — they were greyed before this change too, so nothing is lost; drive the print from Bambuddy, or use Proxy Mode, where the slicer talks to the printer directly and they work. `print_error` is never mirrored either: a fault on the printer would raise a modal error dialog in the slicer for a machine that did not throw it, and the printer's own card already reports it. **The upload handshake wins.** Mirroring is suppressed while a job is being handed over (`gcode_state=PREPARE`) and for five seconds after the last upload transition — the slicer only releases its in-flight-job lock when it sees FINISH carrying the `subtask_name` it just uploaded, so swapping in the printer's filename mid-handshake would wedge the send modal at "Downloading". Once settled, the report switches to the job that is actually on the bed, which is the one the user wants to watch. **Tests.** 8 cases: progress mirrors while the target prints; the mirrored state is never one the slicer reads as busy (parametrised over RUNNING and PAUSE — this is the assertion that keeps #1558 fixed); progress stays zeroed while the target is idle, while an upload is in flight, and inside the settle window, where the slicer's own filename is still echoed back at it; mirroring resumes once the handshake has settled; `print_error` is suppressed while the rest still mirrors. Verified by mutation — forcing the mirror off fails five of the eight. **Scope.** Backend only, non-proxy VPs with a target printer bound. No DB migration, no schema change, no new setting, no new permission, no i18n change.
- **Slicer Pipelines — multi-copy batches, class targeting, fanout strategies, runs dashboard, retry-failed, live WS updates (#1425 PR C — completes the v3 design)** — The PR A/B drop turned slice-modal preset bundles into one-click dispatches with a pinned target printer. PR C closes the original issue with full production-batch semantics: an operator picks a saved pipeline, types in a number of copies, and Bambuddy slices once and distributes the prints across a fleet according to the pipeline's chosen fanout strategy. The runs dashboard surfaces every active and historical run with filters, per-row expandable per-copy status, cancel-in-flight, and retry-failed-copies. WebSocket pushes keep the dashboard and the in-Settings "Last run" chip live without polling. **Backend.** `PipelineRunCreateRequest.copies` (Pydantic `ge=1, le=1000`) replaces the implicit 1 from PR B; the orchestration loop creates one `PipelineJob` row per copy. `SlicerPipelineUpdate` accepts `target_kind` (`specific_printer` / `printer_class`), `target_model_class` (Bambu model code: A1 / A1 Mini / P1P / P1S / P2S / X1 / X1C / X1E / H2D / H2D Pro / H2C / X2D), and `fanout_strategy` (`max_parallel` / `round_robin` / `fill_one_first`). A new `pipeline_max_copies` setting (default 50, Pydantic `ge=1, le=1000`) gates the copies input in the Run-with-pipeline modal and is enforced again at `POST /run` time so an API caller can't bypass the cap. PR C also adds `PipelineRun.parent_run_id` (nullable FK to itself, ON DELETE SET NULL) so retry runs link back to the run whose failed copies they re-attempt. **Eligibility for class targeting.** The matcher in `services/pipeline_eligibility.py` now branches on `pipeline.target_kind`: the specific-printer path is unchanged (PR B parity), the new class-targeting path enumerates every `Printer` whose `model` matches `pipeline.target_model_class`, runs the per-printer slot-by-slot check for each via a `status_lookup` closure that the route handler hands in (so the matcher stays pure-ish for unit tests), and returns a top-level `printer_reports: list[PerPrinterReport]` with `ok` derived as `any` across the candidates. New issue kinds: `no_class_matches` (the install has zero printers in the chosen model class) and `class_not_set` (target_kind is `printer_class` but no model was picked). The lenient-policy story is the same — operators can `Run anyway` past blocking issues, and `PipelineRun.eligibility_overridden` is set so the audit trail shows it. **Orchestration + fanout.** A new `_pick_assignments(pipeline, copies)` helper returns `[(printer_id_or_None, target_model_or_None), …]` of length copies per the picked strategy. `max_parallel` sets `target_model=pipeline.target_model_class` on every queue item and leaves `printer_id=None` — the existing print scheduler's model-based dispatch picks any idle matching printer per item; the result is that multiple printers grab work in parallel without any new scheduler code. `round_robin` enumerates eligible printers (`is_active=True`, model matches) ordered by id and assigns copy `i` to `eligible[i % len(eligible)]` — each item gets a fixed `printer_id`, the wear distributes evenly. `fill_one_first` pins every copy to `eligible[0]` so a one-printer fleet stays one-printer even when others come online mid-run; the documented trade-off is that a printer failure freezes the queue at that printer until the operator intervenes. All three flows reuse the same slice-once path; the slice runs through `slice_dispatch.enqueue` exactly as PR B did so the persistent progress toast renders end-to-end for batches just like single-copy runs. **Routes.** `GET /pipeline-runs?limit&offset&pipeline_id&status` is the dashboard endpoint — newest-first, paginated, filterable by pipeline and persisted snapshot status. `POST /pipeline-runs/{id}/retry-failed` counts the parent's failed-or-cancelled jobs at the live (queue-entry-aware) status level, builds a fresh `PipelineRunCreateRequest` with `copies=that count` and `force=True` (operator already accepted eligibility on the parent), routes it through the existing `run_pipeline` handler, and stamps `parent_run_id` on the result. Returns 400 when the parent's source or pipeline was deleted, or when there are no failed copies to retry. `POST /pipeline-runs/{id}/cancel` extends PR B's cancel to cascade across N queue entries — only the ones still in `pending` / `queued` are touched so in-flight prints continue on the printer (operator must Stop on the machine). **WebSocket.** New `pipeline_run_updated` event type carries the full materialised `PipelineRunResponse` and fires on every state transition (`queued → slicing → dispatching → in_progress → completed | failed | partial_failure | cancelled`). Per-user routing via `ws_manager.broadcast_to_user(run.created_by, …)` so each operator sees their own runs without cross-user noise; auth-disabled installs broadcast to all connections (PR B's pattern). The frontend's `useWebSocket` switch handles it by invalidating both `['pipeline-runs-all']` (the dashboard) and `['pipeline-runs', pipeline_id]` (the per-pipeline "Last run" chip in Settings). The dashboard still polls every 15 s as a belt-and-suspenders for missed messages. **Run status roll-up.** A new `_roll_up_run_status` function computes the run-level status from the per-job statuses at read time: all-completed → `completed`, any in-flight → `in_progress`, some completed + some failed → the new `partial_failure` status (this is what gets the Retry-failed button), all failed → `failed`. The persisted snapshot is still written on terminal transitions for the dashboard's status filter to remain useful. `copies_completed` / `_failed` / `_cancelled` / `_in_progress` counts ride on the response so per-row "1/3 · 2 failed" summaries don't need a second query. **Frontend.** The Settings → Workflow → Pipelines pipeline editor grows three new controls in the edit form: a radio for `target_kind` (Specific printer / Printer class), a model-class picker filtered to the models present on at least one installed `Printer` row (so users can't pick "H2C" if they only have X1Cs), and a fanout-strategy radio with the three options labelled with their use cases. The read-only row reflects class targeting with a "X1C · Round robin" line in place of the printer name. `RunWithPipelineModal` grows a number input for copies bounded by `settings.pipeline_max_copies`, accepts class-targeted pipelines (the "Apply pipeline" button is enabled when the pipeline has either a pinned printer OR a class target), and the pipeline-list row shows "Any X1C" instead of a printer name for class pipelines. The "Run pipeline" Setting → Workflow → Queue & Dispatch sub-tab gets a new "Slicer Pipeline limits" card with the max-copies input (bounded 1–1000 client-side, server enforces the same). **New dashboard page at `/pipelines/runs`** (sidebar entry under Print Queue, gated on `pipelines:read`). Lists every run across every pipeline with two dropdown filters (pipeline + persisted snapshot status) and pagination at 25 per page. Each row shows pipeline name, status chip (`partial_failure` is amber), source file, created-at timestamp, and "{completed}/{copies}" + "{failed} failed" rollup. Click the chevron to expand a per-copy panel listing each `PipelineJob`'s assigned printer + status + error message. In-flight runs get a Cancel button; partial-failure / failed runs get a Retry-failed button. **i18n.** ~43 new keys across `nav.pipelineRuns`, `pipelineRuns.*` (title / filters / pagination / job-status chips / toasts), `settings.pipelines.field.*` (targetKind / fanout / class), `settings.pipelines.runs.status.partial_failure`, `settings.pipelineLimits.*`, `library.runWithPipeline.*` (copies / copiesHint / classTarget / issue.noClassMatches / issue.classNotSet), and `common.previous` / `common.next` — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5516 leaves per locale, no English fallback. `Copies` / `{{n}} copies` / `max {{n}}` added to the French + Italian cognate allowlists where they're genuine. **Tests.** Six new backend cases in `test_pipeline_runs_api.py` covering copies-cap rejection (schema gate at 1000), 3-copy run creates 3 jobs with sequential `copy_index`, class eligibility with two X1C candidates returns a 2-entry `printer_reports` array, class eligibility with no matching printers in install returns `no_class_matches`, dashboard list endpoint with pagination + status filter, retry-failed correctly counts failed jobs from a partial-failure parent and stamps `parent_run_id`. Plus the existing 16 PR A/B cases were lightly updated where `class_not_set` is now a valid no-target signal alongside `printer_not_set`. Five new frontend cases in `PipelineRunsPage.test.tsx` pin the dashboard's empty state, list rendering, Cancel button on in-flight runs, Retry-failed button on partial-failure runs, and per-row expand to show jobs. Three updated frontend cases (`RunWithPipelineModal.test.tsx`) assert the new four-arg signature on `runPipeline` (`pipelineId, source, force, copies`). One updated `SettingsPage.test.tsx` sidebar-order test reflects the new `pipelineRuns` nav entry between `queue` and `projects`. **Suites.** `pytest -n 30 backend/tests/` 6539/6539 green; `npx vitest run` 2284/2284 green (173 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** PR C closes the v3 design — no further pipeline PRs are queued. The existing print scheduler's model-based dispatch (`PrintQueueItem.target_model` + `target_location` + `required_filament_types`) is the only thing that makes class targeting actually distribute work; PR C just plugs into it. The `fill_one_first` strategy's "one printer fails, queue stalls" trade-off is documented in the editor's option-row hover-hint and in the orchestrator code comment — it's the correct behaviour for "I want one printer to finish a batch end-to-end" and the wrong behaviour for "I want resilience"; the right strategy for resilience is `max_parallel`. **Cross-printer-class pipelines** (e.g. one pipeline targeting "any X1C OR P1S") remain out of scope — make two pipelines, one per class.
- **Slicer Pipelines — Archive entry point + progress toast for pipeline-driven slicing (#1425 PR B follow-up)** — Two real gaps from the PR B drop. (1) The Run-with-pipeline button only existed in the file manager — operators who keep their working files in archives had to copy them out to the library to use a pipeline. (2) Triggering a slice via a pipeline produced a silent multi-second-to-minute wait — the manual SliceModal flow has the sticky `Slicing X — Generating G-code 75%` persistent toast, the pipeline path went through `asyncio.create_task` directly and never registered with `SliceJobTracker`. **Fix.** (1) `POST /slicer-pipelines/{id}/check-eligibility` and `POST /slicer-pipelines/{id}/run` now accept `source_archive_id` as an alternative to `source_library_file_id` (XOR — Pydantic validator rejects both-set and neither-set), and the eligibility-check and orchestration paths branch via `_resolve_source` which reads `archive.source_3mf_path` with a fallback to `archive.file_path`. `PipelineRun.source_archive_id` is a new nullable FK column (Postgres + SQLite `ALTER TABLE` in `run_migrations` — idempotent via `_safe_execute`). `PipelineRunResponse` echoes the field. ArchiveCard's context menu picks up a `Run with pipeline` item alongside the existing Slice action (only on source archives — gcode archives already have Print + Open in BambuStudio), gated on `useSlicerApi` + `pipelines:run`. Path-safety: `Path(base_dir) / archive.source_3mf_path` carries a `SEC-PATH-OK` marker citing the upload-time validator at `_resolve_source_3mf_path` (same comment style as `routes/archives.py:3955`); the `LibraryFile.file_path` site gets the same treatment. (2) The pipeline orchestrator is now the `run` callable of a `slice_dispatch.enqueue` call — the same dispatcher the manual `SliceModal` flow uses — instead of a bare `asyncio.create_task`. The SliceJob's lifecycle (`pending → running → completed/failed`) drives the existing progress toast end to end: same persistent toast, same `Generating G-code 75%` weave from the sidecar's `--pipe` channel, same auto-replace with a transient success/error toast on terminal. `PipelineRun.slice_job_id` is set on the run row before the route returns 202, so the frontend can call `useSliceJobTracker().trackJob(slice_job_id, source.kind, source.filename)` from `RunWithPipelineModal`'s `runMutation.onSuccess` — same one-call surface that `SliceModal`'s slice mutation already uses. (3) `RunWithPipelineModal`'s `source` prop is now `{kind: 'libraryFile' | 'archive', id, filename}` (mirrors `SliceModal.SliceSource`); `api.checkPipelineEligibility` + `api.runPipeline` take a discriminated-union source argument and route to the right backend field. `PipelineRun` TS type grows `source_archive_id`. **Tests.** Three new backend cases in `test_pipeline_runs_api.py` — archive-source happy path (creates a PrintArchive row + on-disk file, posts with `source_archive_id`, verifies the response carries `source_archive_id` + `slice_job_id` from a stubbed `slice_dispatch.enqueue`), XOR rejection both-set, XOR rejection neither-set. The existing three run/cancel cases were updated to patch `backend.app.services.slice_dispatch.slice_dispatch.enqueue` (the new mock target) instead of the removed `_run_pipeline_orchestration` helper, and the run-happy-path now asserts `slice_job_id == 9001` arrives on the response. One new frontend case in `RunWithPipelineModal.test.tsx` pins the archive flow end to end (`checkPipelineEligibility` called with `{kind: 'archive', id: 7}`, then `runPipeline` with the same). The existing fast/slow path tests were updated to wrap in `SliceJobTrackerProvider` (the new `useSliceJobTracker` hook requires it) and to assert the new discriminated-union source argument. **Suites.** `pytest -n 30 backend/tests/` 6533/6533 green; `npx vitest run` 2279/2279 green (172 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** No new i18n keys — both fixes reuse the existing PR B keys. No new permission. The archive flow only branches at the source-resolution layer; everything downstream (eligibility, slice, queue dispatch) is the same code path the library flow uses. PR C scope (multi-copy + class targeting + fanout) is unchanged.
diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py
index 4a94b1bf0..de987af18 100644
--- a/backend/app/api/routes/auth.py
+++ b/backend/app/api/routes/auth.py
@@ -1617,10 +1617,14 @@ async def provision_ldap_user(
# =============================================================================
# Long-lived camera-stream tokens (#1108)
# =============================================================================
-# Camera-only V1. Issue scope: a token a user can paste into Home Assistant /
-# Frigate / a kiosk and have it keep working for days/weeks rather than
-# refreshing the 60-minute ephemeral token. Permission gate: CAMERA_VIEW
-# (same blast radius as the existing 60-min token-mint endpoint).
+# A token a user can paste into Home Assistant / Frigate / a kiosk and have it
+# keep working for days/weeks rather than refreshing the 60-minute ephemeral
+# token. Permission gate: CAMERA_VIEW (same blast radius as the existing 60-min
+# token-mint endpoint).
+#
+# Two scopes, both minted here — see ALLOWED_SCOPES in services/long_lived_tokens
+# for what each one reaches: "camera_stream" (video only) and "camwall" (video
+# plus the Cam Wall's read-only tile metadata, #2531).
def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:
diff --git a/backend/app/api/routes/camwall.py b/backend/app/api/routes/camwall.py
new file mode 100644
index 000000000..b2de65ed6
--- /dev/null
+++ b/backend/app/api/routes/camwall.py
@@ -0,0 +1,95 @@
+"""Read-only Cam Wall feed for token-authenticated kiosk displays (#2531).
+
+The Cam Wall inside the SPA runs on the ordinary printers API, behind a JWT. A
+wall pinned to a TV has no login, so it authenticates with a long-lived
+``camwall``-scoped token carried in the URL — and a URL on a lobby screen is
+about as private as a sticky note.
+
+That is why this endpoint exists instead of letting a token through to
+``GET /printers``: the printer list carries ``serial_number`` and
+``ip_address`` (see ``schemas/printer.py``), and neither belongs on a screen in
+a shared room. What a wall tile actually draws is the whole payload here — a
+name, a connection flag, a state, a progress bar.
+
+Notably absent is the print filename. A token wall renders the compact status
+overlay, so the part being printed is never named to the room; the field simply
+isn't served rather than being served and then hidden client-side.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.models.printer import Printer
+from backend.app.services.printer_manager import printer_manager
+
+_logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/camwall", tags=["camwall"])
+
+
+@router.get("/printers")
+async def list_camwall_printers(
+ _: None = RequireCamWallTokenIfAuthEnabled,
+ db: AsyncSession = Depends(get_db),
+) -> list[dict]:
+ """Every printer plus the handful of status fields a Cam Wall tile draws.
+
+ One call for the whole wall rather than one per printer: a kiosk polls this
+ on a fixed interval with no WebSocket to invalidate it, and N+1 requests
+ every few seconds is a poor trade for a screen nobody is interacting with.
+
+ Ordered by name so tile positions stay put across polls — a wall that
+ reshuffles itself is unusable to watch.
+ """
+ result = await db.execute(select(Printer).order_by(Printer.name))
+ printers = list(result.scalars().all())
+
+ payload: list[dict] = []
+ for printer in printers:
+ state = printer_manager.get_status(printer.id)
+ entry: dict = {
+ "id": printer.id,
+ "name": printer.name,
+ "camera_rotation": printer.camera_rotation or 0,
+ # Mirrors get_printer_status(): no state object at all means the
+ # printer was never connected this run; a state object still has
+ # to be asked whether its link is currently up.
+ "connected": bool(state and state.connected),
+ "state": None,
+ "progress": None,
+ "remaining_time": None,
+ "layer_num": None,
+ "total_layers": None,
+ # Codes only — enough for the client to run the same
+ # filterKnownHMSErrors() it uses on the authenticated wall, so the
+ # error chip means the same thing in both modes.
+ "hms_errors": [],
+ }
+ if state is not None:
+ entry.update(
+ {
+ "state": state.state,
+ "progress": state.progress,
+ "remaining_time": state.remaining_time,
+ "layer_num": state.layer_num,
+ "total_layers": state.total_layers,
+ "hms_errors": [
+ {
+ "code": e.code,
+ "attr": e.attr,
+ "module": e.module,
+ "severity": e.severity,
+ "actions": e.actions or [],
+ }
+ for e in (state.hms_errors or [])
+ ],
+ }
+ )
+ payload.append(entry)
+
+ return payload
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
index f9557799d..820e4ef0a 100644
--- a/backend/app/core/auth.py
+++ b/backend/app/core/auth.py
@@ -700,9 +700,27 @@ async def verify_camera_stream_token(token: str) -> bool:
# Long-lived path. Imported lazily so the auth module stays importable
# at startup before the long_lived_tokens model is registered.
+ from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
+
+ record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
+ return record is not None
+
+
+async def verify_camwall_token(token: str) -> bool:
+ """Verify a Cam Wall token (#2531). Reusable — does not consume it.
+
+ Deliberately narrower than :func:`verify_camera_stream_token`: only the
+ long-lived ``camwall`` scope passes. The 60-minute ephemeral token belongs
+ to a logged-in browser, which already reaches the wall's metadata through
+ the ordinary printers API and has no need of this endpoint; and a
+ ``camera_stream`` token was handed out for video alone, so it must not
+ acquire the ability to enumerate printers by name just because a new
+ feature shipped.
+ """
+ async with async_session() as db:
from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
- record = await verify_long_lived(db, token, scope="camera_stream")
+ record = await verify_long_lived(db, token, scope="camwall")
return record is not None
@@ -1673,6 +1691,29 @@ def require_camera_stream_token_if_auth_enabled():
RequireCameraStreamTokenIfAuthEnabled = Depends(require_camera_stream_token_if_auth_enabled())
+def require_camwall_token_if_auth_enabled():
+ """Dependency that validates a Cam Wall token query param when auth is enabled.
+
+ Used by the read-only Cam Wall feed (#2531), which a kiosk browser loads
+ with the token in the URL because it has no login session to carry a JWT.
+ """
+
+ async def checker(token: str | None = None) -> None:
+ async with async_session() as db:
+ if not await is_auth_enabled(db):
+ return # Auth disabled, allow access
+ if not token or not await verify_camwall_token(token):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Valid Cam Wall token required. Create one under Settings > API Keys with the 'Cam Wall' scope.",
+ )
+
+ return checker
+
+
+RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
+
+
def require_ownership_permission(
all_permission: str | Permission,
own_permission: str | Permission,
diff --git a/backend/app/main.py b/backend/app/main.py
index 4af28cc45..cd6fe1f3d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -25,6 +25,7 @@ from backend.app.api.routes import (
auth,
bug_report,
camera,
+ camwall,
cloud,
discovery,
external_links,
@@ -6460,6 +6461,14 @@ PUBLIC_API_ROUTES = {
# before the route handler runs, regardless of the route's own
# "no auth required" intent.
"/api/v1/system/appliance",
+ # Cam Wall kiosk feed (#2531): a TV or Pi in kiosk mode has no login, so it
+ # authenticates with a long-lived ``camwall``-scoped token in the query
+ # string — exactly like the camera streams two lists below, and for the same
+ # reason (no header to put a JWT in). "Public" here only means the middleware
+ # steps aside; the route still runs RequireCamWallTokenIfAuthEnabled, which
+ # rejects an absent, expired, revoked, or wrong-scoped token. In particular a
+ # plain ``camera_stream`` token does NOT open this door.
+ "/api/v1/camwall/printers",
}
# Route prefixes that are public (for routes with dynamic segments)
@@ -6851,6 +6860,7 @@ app.include_router(updates.router, prefix=app_settings.api_prefix)
app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
app.include_router(camera.router, prefix=app_settings.api_prefix)
+app.include_router(camwall.router, prefix=app_settings.api_prefix)
app.include_router(external_links.router, prefix=app_settings.api_prefix)
app.include_router(projects.router, prefix=app_settings.api_prefix)
app.include_router(library.router, prefix=app_settings.api_prefix)
diff --git a/backend/app/services/long_lived_tokens.py b/backend/app/services/long_lived_tokens.py
index c2b8593f5..7ef1b67a2 100644
--- a/backend/app/services/long_lived_tokens.py
+++ b/backend/app/services/long_lived_tokens.py
@@ -22,6 +22,7 @@ tokens — a leaked permanent token would be irrevocable footgun-by-design).
from __future__ import annotations
import secrets
+from collections.abc import Collection
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@@ -35,9 +36,20 @@ from backend.app.models.long_lived_token import LongLivedToken
# (90 days) and the create route enforces this ceiling.
MAX_TOKEN_LIFETIME_DAYS = 365
-# Only V1 scope. Adding "snapshot" or "control" later means adding a value
-# to this tuple and an `if scope == ...` branch in the route, no schema work.
-ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream"})
+# Every scope is a separate grant, never implied by another. A token minted for
+# one purpose must not silently widen when a later scope is added.
+#
+# camera_stream — the MJPEG stream / snapshot endpoints and nothing else
+# (#1108). What a Home Assistant or Frigate card needs.
+# camwall — those same streams *plus* the read-only tile metadata the
+# Cam Wall draws: printer names and print state (#2531).
+# Strictly wider than camera_stream, so it gets its own scope
+# rather than quietly extending tokens already handed out.
+ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall"})
+
+# Scopes the camera stream / snapshot endpoints honour. A Cam Wall token has to
+# be able to pull the video its own tiles are showing.
+STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall")
# Don't write to last_used_at more than once per minute per token. MJPEG
# streams call verify() at most once per fetch (the browser holds the
@@ -142,23 +154,34 @@ async def create_token(
return CreatedToken(record=record, plaintext=plaintext)
-async def verify_token(db: AsyncSession, token: str, *, scope: str = "camera_stream") -> LongLivedToken | None:
+async def verify_token(
+ db: AsyncSession,
+ token: str,
+ *,
+ scope: str | Collection[str] = "camera_stream",
+) -> LongLivedToken | None:
"""Validate a token. Returns the matching record on success, None otherwise.
- The bcrypt-style verify is the slow step (intentional — pbkdf2 by design),
- so we pre-filter by the indexed ``lookup_prefix`` to ensure the verify
- runs against at most one or two candidate rows.
+ ``scope`` accepts a single scope or a collection of acceptable ones — the
+ stream endpoints pass ``STREAM_SCOPES`` because more than one scope may
+ legitimately reach them. The record must carry one of them; a token is
+ never accepted on the strength of a scope it does not hold.
+
+ The pbkdf2 verify is the slow step (intentional), so we pre-filter by the
+ indexed ``lookup_prefix`` to ensure the verify runs against at most one or
+ two candidate rows.
"""
parsed = _parse_token(token)
if parsed is None:
return None
lookup_prefix, full_token = parsed
+ scopes = (scope,) if isinstance(scope, str) else tuple(scope)
now = datetime.now(timezone.utc)
result = await db.execute(
select(LongLivedToken).where(
LongLivedToken.lookup_prefix == lookup_prefix,
- LongLivedToken.scope == scope,
+ LongLivedToken.scope.in_(scopes),
LongLivedToken.revoked_at.is_(None),
)
)
diff --git a/backend/tests/integration/test_camwall_api.py b/backend/tests/integration/test_camwall_api.py
new file mode 100644
index 000000000..236ad8531
--- /dev/null
+++ b/backend/tests/integration/test_camwall_api.py
@@ -0,0 +1,203 @@
+"""Integration tests for the token-authenticated Cam Wall feed (#2531).
+
+The feature's whole reason for existing as a separate endpoint (rather than
+letting a token through to ``GET /printers``) is that a kiosk URL is not a
+secret. So the tests that matter here are the negative ones: what a Cam Wall
+token *cannot* reach, and what the payload *does not* contain.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
+ await async_client.post(
+ "/api/v1/auth/setup",
+ json={
+ "auth_enabled": True,
+ "admin_username": f"camwalladmin{suffix}",
+ "admin_password": "AdminPass1!",
+ },
+ )
+ login = await async_client.post(
+ "/api/v1/auth/login",
+ json={"username": f"camwalladmin{suffix}", "password": "AdminPass1!"},
+ )
+ return login.json()["access_token"]
+
+
+async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "kiosk") -> str:
+ response = await async_client.post(
+ "/api/v1/auth/tokens",
+ headers={"Authorization": f"Bearer {jwt}"},
+ json={"name": name, "expires_in_days": 30, "scope": scope},
+ )
+ assert response.status_code == 201, response.text
+ assert response.json()["scope"] == scope
+ return response.json()["token"]
+
+
+@pytest.fixture
+async def printer_row(db_session):
+ """Insert the printer straight into the DB.
+
+ POST /printers probes the real device before it will store a row, and there
+ is no printer on the other end of a test run.
+ """
+ from backend.app.models.printer import Printer
+
+ printer = Printer(
+ name="Wall P1S",
+ ip_address="192.168.1.77",
+ access_code="12345678",
+ serial_number="01P00A000000001",
+ model="P1S",
+ )
+ db_session.add(printer)
+ await db_session.commit()
+ return printer
+
+
+class TestCamWallFeedAuth:
+ async def test_no_token_is_rejected(self, async_client: AsyncClient):
+ await _setup_admin(async_client, suffix="_notoken")
+ response = await async_client.get("/api/v1/camwall/printers")
+ assert response.status_code == 401
+
+ async def test_garbage_token_is_rejected(self, async_client: AsyncClient):
+ await _setup_admin(async_client, suffix="_garbage")
+ response = await async_client.get("/api/v1/camwall/printers?token=bblt_aaaaaaaa_nope")
+ assert response.status_code == 401
+
+ async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient):
+ """The point of the separate scope.
+
+ ``camera_stream`` tokens are already in the wild, minted by users who
+ agreed to hand out *video*. Shipping the Cam Wall must not retroactively
+ grant them the ability to enumerate printers by name.
+ """
+ jwt = await _setup_admin(async_client, suffix="_wrongscope")
+ stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+ response = await async_client.get(f"/api/v1/camwall/printers?token={stream_token}")
+ assert response.status_code == 401
+
+ async def test_camwall_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
+ jwt = await _setup_admin(async_client, suffix="_rightscope")
+ camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+ response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert len(body) == 1
+ assert body[0]["name"] == "Wall P1S"
+
+ async def test_revoked_camwall_token_is_rejected(self, async_client: AsyncClient):
+ jwt = await _setup_admin(async_client, suffix="_revoked")
+ created = await async_client.post(
+ "/api/v1/auth/tokens",
+ headers={"Authorization": f"Bearer {jwt}"},
+ json={"name": "kiosk", "expires_in_days": 30, "scope": "camwall"},
+ )
+ camwall_token = created.json()["token"]
+ await async_client.delete(
+ f"/api/v1/auth/tokens/{created.json()['id']}",
+ headers={"Authorization": f"Bearer {jwt}"},
+ )
+
+ response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+ assert response.status_code == 401
+
+
+class TestCamWallFeedPayload:
+ async def test_payload_withholds_secrets_and_filenames(self, async_client: AsyncClient, printer_row):
+ """A URL taped to a TV must not disclose more than the picture does.
+
+ Serial number and IP ride along on the ordinary printer list even for
+ non-secret callers, and the filename names the customer's part. None of
+ the three may appear here.
+ """
+ jwt = await _setup_admin(async_client, suffix="_payload")
+ camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+ response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+ assert response.status_code == 200
+ entry = response.json()[0]
+
+ for leaked in ("serial_number", "ip_address", "access_code", "subtask_name", "gcode_file"):
+ assert leaked not in entry, f"{leaked} must not be served to a kiosk token"
+
+ assert set(entry) == {
+ "id",
+ "name",
+ "camera_rotation",
+ "connected",
+ "state",
+ "progress",
+ "remaining_time",
+ "layer_num",
+ "total_layers",
+ "hms_errors",
+ }
+
+ async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
+ """No MQTT client is running in tests, so the printer has no state at
+ all — the tile must render as offline rather than blank.
+ """
+ jwt = await _setup_admin(async_client, suffix="_offline")
+ camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+ response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+ entry = response.json()[0]
+ assert entry["connected"] is False
+ assert entry["state"] is None
+ assert entry["hms_errors"] == []
+
+
+class TestCamWallTokenReachesTheVideo:
+ """A wall that can list the tiles but not fill them is useless — the same
+ token has to satisfy the camera-stream gate.
+ """
+
+ async def test_camwall_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
+ from backend.app.core.auth import verify_camera_stream_token
+
+ jwt = await _setup_admin(async_client, suffix="_video")
+ camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+ assert await verify_camera_stream_token(camwall_token) is True
+
+ async def test_camera_stream_token_still_passes_its_own_gate(self, async_client: AsyncClient):
+ """Regression guard on #1108: widening the accepted scopes must not have
+ broken the tokens that were already working.
+ """
+ from backend.app.core.auth import verify_camera_stream_token
+
+ jwt = await _setup_admin(async_client, suffix="_video_legacy")
+ stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+ assert await verify_camera_stream_token(stream_token) is True
+
+ async def test_camwall_gate_rejects_a_camera_stream_token(self, async_client: AsyncClient):
+ from backend.app.core.auth import verify_camwall_token
+
+ jwt = await _setup_admin(async_client, suffix="_gate_narrow")
+ stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+ assert await verify_camwall_token(stream_token) is False
+
+
+class TestScopeValidation:
+ async def test_unknown_scope_is_rejected_at_mint(self, async_client: AsyncClient):
+ jwt = await _setup_admin(async_client, suffix="_badscope")
+ response = await async_client.post(
+ "/api/v1/auth/tokens",
+ headers={"Authorization": f"Bearer {jwt}"},
+ json={"name": "x", "expires_in_days": 30, "scope": "printers_write"},
+ )
+ assert response.status_code == 400
+ assert "unsupported scope" in response.json()["detail"].lower()
diff --git a/backend/tests/unit/services/test_long_lived_tokens.py b/backend/tests/unit/services/test_long_lived_tokens.py
index 301ae4048..77c18f593 100644
--- a/backend/tests/unit/services/test_long_lived_tokens.py
+++ b/backend/tests/unit/services/test_long_lived_tokens.py
@@ -107,8 +107,13 @@ async def test_create_rejects_expiry_above_policy_cap(db_session, alice: User):
async def test_create_rejects_unsupported_scope(db_session, alice: User):
- """V1 only allows ``camera_stream``."""
- assert {"camera_stream"} == set(ALLOWED_SCOPES)
+ """The scope set is closed: ``camera_stream`` (#1108) and ``camwall`` (#2531).
+
+ Pinned deliberately. Adding a scope should be a decision someone makes on
+ purpose — a new value here means a new class of thing a URL-borne token can
+ reach, so it should not be possible to add one without this line failing.
+ """
+ assert {"camera_stream", "camwall"} == set(ALLOWED_SCOPES)
with pytest.raises(ValueError, match="unsupported scope"):
await create_token(
db_session,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2e22ed17a..ead293d2f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -14,6 +14,7 @@ import { ProjectDetailPage } from './pages/ProjectDetailPage';
import { FileManagerPage } from './pages/FileManagerPage';
import { LibraryTrashPage } from './pages/LibraryTrashPage';
import { CameraPage } from './pages/CameraPage';
+import { CamWallPage } from './pages/CamWallPage';
import { StreamOverlayPage } from './pages/StreamOverlayPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { GroupEditPage } from './pages/GroupEditPage';
@@ -182,6 +183,11 @@ function App() {
{/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
} />
+ {/* Cam Wall on its own URL (#2531). Outside ProtectedRoute because a
+ ?token= kiosk has no session to protect; the page itself sends a
+ tokenless visitor to /login, and the backend gates the feed. */}
+ } />
+
{/* SpoolBuddy kiosk UI */}
}>
} />
diff --git a/frontend/src/__tests__/pages/CamWallPage.test.tsx b/frontend/src/__tests__/pages/CamWallPage.test.tsx
new file mode 100644
index 000000000..19227b45d
--- /dev/null
+++ b/frontend/src/__tests__/pages/CamWallPage.test.tsx
@@ -0,0 +1,145 @@
+/**
+ * Standalone Cam Wall page (#2531).
+ *
+ * The assertions that carry weight are the kiosk ones: a wall on a TV must not
+ * offer controls it cannot honour, must not name the file on the bed, and must
+ * carry its token into the URLs — an MJPEG tag has no Authorization
+ * header, so a missing token means a wall of broken images.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render as rtlRender } from '@testing-library/react';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { AuthProvider } from '../../contexts/AuthContext';
+import { CamWallPage } from '../../pages/CamWallPage';
+import { api, getStreamToken } from '../../api/client';
+
+const KIOSK_TOKEN = 'bblt_abcdefgh_secretsecretsecret';
+
+const FEED = [
+ {
+ id: 7,
+ name: 'X1C-Lab',
+ camera_rotation: 0,
+ connected: true,
+ state: 'RUNNING',
+ progress: 42,
+ remaining_time: 33,
+ layer_num: 120,
+ total_layers: 300,
+ hms_errors: [],
+ },
+];
+
+// The page renders outside the app layout, so it supplies its own route context.
+// The shared render() util hard-codes BrowserRouter with no way to seed a query
+// string, and the query string is the whole point here.
+function renderAt(search: string) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ return rtlRender(
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe('CamWallPage — kiosk mode', () => {
+ beforeEach(() => {
+ vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED);
+ // AuthProvider probes /auth/me on mount; a kiosk browser has no session.
+ vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 }));
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('fetches the wall with the URL token and renders a tile per printer', async () => {
+ renderAt(`?token=${KIOSK_TOKEN}`);
+
+ await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+ expect(api.getCamWallPrinters).toHaveBeenCalledWith(KIOSK_TOKEN);
+ });
+
+ it('carries the token into the stream URL', async () => {
+ renderAt(`?token=${KIOSK_TOKEN}`);
+
+ await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+ // Tiles start paused (jsdom's IntersectionObserver never reports a tile as
+ // on-screen), and a paused tile renders no at all. So assert on the
+ // module-level token those URLs are built from, which is what CameraTile
+ // reads through withStreamToken().
+ expect(getStreamToken()).toBe(KIOSK_TOKEN);
+ });
+
+ it('offers no settings popover — a TV has nobody standing at it', async () => {
+ renderAt(`?token=${KIOSK_TOKEN}`);
+
+ await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+ expect(screen.queryByTitle('Cam wall settings')).not.toBeInTheDocument();
+ });
+
+ it('renders tiles inert — click-through would need a session the token has not got', async () => {
+ renderAt(`?token=${KIOSK_TOKEN}`);
+
+ await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+ expect(screen.queryByRole('button', { name: /X1C-Lab/ })).not.toBeInTheDocument();
+ });
+
+ it('refuses to honour ?status=full — a kiosk wall never names the part on the bed', async () => {
+ renderAt(`?token=${KIOSK_TOKEN}&status=full`);
+
+ await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+ // 'full' is what adds the progress/layer strip. Capped to 'compact', so the
+ // strip must be absent even though the feed says the printer is at 42%.
+ expect(screen.queryByText('42%')).not.toBeInTheDocument();
+ expect(screen.queryByText(/Layer 120/)).not.toBeInTheDocument();
+ });
+
+ it('says so plainly when the token has expired or been revoked', async () => {
+ vi.spyOn(api, 'getCamWallPrinters').mockRejectedValue(new Error('401'));
+ renderAt(`?token=${KIOSK_TOKEN}`);
+
+ await waitFor(() =>
+ expect(screen.getByText(/no longer valid/i)).toBeInTheDocument(),
+ );
+ });
+});
+
+describe('CamWallPage — signed out, no token', () => {
+ beforeEach(() => {
+ vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED);
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('does not reach for the kiosk feed without a token', async () => {
+ vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 }));
+ renderAt('');
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ // No token means this is an ordinary app page; it must fall back to the
+ // session-authenticated printers API (or bounce to /login), never to the
+ // kiosk endpoint.
+ expect(api.getCamWallPrinters).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 2b33b672a..14a71594b 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -287,14 +287,20 @@ export interface SystemHealthResult {
};
}
-// Long-lived camera-stream tokens (#1108). The `token` field is populated
-// only on the create response — listing endpoints set it to null because
-// the plaintext value is shown to the user exactly once.
+// Long-lived camera tokens (#1108). The `token` field is populated only on the
+// create response — listing endpoints set it to null because the plaintext
+// value is shown to the user exactly once.
+//
+// 'camera_stream' reaches the video endpoints only. 'camwall' additionally
+// reaches the read-only Cam Wall feed, which names the printers (#2531), so it
+// is a separate scope rather than a widening of tokens already in the wild.
+export type LongLivedTokenScope = 'camera_stream' | 'camwall';
+
export interface LongLivedCameraToken {
id: number;
user_id: number;
name: string;
- scope: 'camera_stream';
+ scope: LongLivedTokenScope;
lookup_prefix: string;
created_at: string;
expires_at: string;
@@ -302,6 +308,22 @@ export interface LongLivedCameraToken {
token: string | null;
}
+// One row of the token-authenticated Cam Wall feed (#2531). Deliberately
+// smaller than PrinterStatus: no serial, no IP, no print filename — a kiosk URL
+// is not a secret, so the payload behind it must not be either.
+export interface CamWallPrinter {
+ id: number;
+ name: string;
+ camera_rotation: number;
+ connected: boolean;
+ state: string | null;
+ progress: number | null;
+ remaining_time: number | null;
+ layer_num: number | null;
+ total_layers: number | null;
+ hms_errors: HMSError[];
+}
+
// Printer types
export interface Printer {
id: number;
@@ -5645,11 +5667,15 @@ export const api = {
getWebSocketToken: () =>
request<{ token: string }>('/auth/ws-token', { method: 'POST' }),
- // Long-lived camera-stream tokens (#1108)
- createLongLivedCameraToken: (payload: { name: string; expires_in_days: number }) =>
+ // Long-lived camera tokens (#1108, #2531)
+ createLongLivedCameraToken: (payload: {
+ name: string;
+ expires_in_days: number;
+ scope?: LongLivedTokenScope;
+ }) =>
request('/auth/tokens', {
method: 'POST',
- body: JSON.stringify({ ...payload, scope: 'camera_stream' }),
+ body: JSON.stringify({ scope: 'camera_stream', ...payload }),
}),
listMyLongLivedCameraTokens: () =>
request('/auth/tokens'),
@@ -5659,6 +5685,12 @@ export const api = {
request(`/auth/tokens?user_id=${userId}`),
revokeLongLivedCameraToken: (tokenId: number) =>
request(`/auth/tokens/${tokenId}`, { method: 'DELETE' }),
+ // Token-authenticated Cam Wall feed (#2531). `token` is omitted only when
+ // auth is disabled, where the backend gate is a no-op anyway.
+ getCamWallPrinters: (token?: string) =>
+ request(
+ token ? `/camwall/printers?token=${encodeURIComponent(token)}` : '/camwall/printers',
+ ),
getCameraStreamUrl: (printerId: number, fps = 10) =>
withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
getCameraSnapshotUrl: (printerId: number) =>
diff --git a/frontend/src/components/CameraTile.tsx b/frontend/src/components/CameraTile.tsx
index bd47f5421..0c0220d22 100644
--- a/frontend/src/components/CameraTile.tsx
+++ b/frontend/src/components/CameraTile.tsx
@@ -128,9 +128,11 @@ export function CameraTile({
`/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
);
- const handleClick = () => {
- if (onClick) onClick();
- };
+ // A kiosk wall passes no onClick — there is no pointer at a TV, and the page
+ // is authenticated by a token that cannot open the single-camera view. Render
+ // the tile as plain, non-focusable content rather than a button that looks
+ // clickable and then does nothing.
+ const interactive = onClick != null;
const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
@@ -145,13 +147,12 @@ export function CameraTile({
const hasLayers = layerNum != null && totalLayers != null && totalLayers > 0;
const hasRemaining = remainingMin != null && remainingMin > 0;
- return (
-