mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
fix(camera): show diagnostic in window-mode camera page (#1395)
The #1395 camera-diagnostic follow-up (stethoscope control-bar icon, Diagnose button in the stream-error state, CameraDiagnoseModal) shipped wired into EmbeddedCameraViewer.tsx only — the embedded camera mode. CameraPage.tsx, the standalone window opened at /camera/{id} when camera_view_mode is "window" (the default), never got it. A reporter on window mode could not see the stethoscope regardless of container rebuilds or cache clears: the camera.diagnose strings are in the bundle but come from EmbeddedCameraViewer, which never renders in window mode. Switching to overlay mode surfaced it instantly. Port the diagnostic into CameraPage.tsx: Stethoscope control-bar button between Refresh and Fullscreen, a Diagnose button next to Retry in the streamError block, and the CameraDiagnoseModal render. No new i18n keys — camera.diagnose.* already exist in all 9 locales. The backend per-model camera-profile fix from the same issue is view-mode-agnostic and unaffected.
This commit is contained in:
@@ -20,6 +20,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean).
|
||||
|
||||
### Fixed
|
||||
- **Camera diagnostic (stethoscope) was missing from the pop-out camera window (#1395, reported by @Tschipel)** — The #1395 camera-diagnostic follow-up — stethoscope icon in the control bar, **Diagnose** button in the stream-error state, `CameraDiagnoseModal` — shipped wired into `EmbeddedCameraViewer.tsx` only, the *embedded* camera mode. It was never added to `CameraPage.tsx`, the standalone window that opens at `/camera/{id}` when `camera_view_mode` is `window` (the default). The reporter's support bundle had `"camera_view_mode": "window"`, so they were on `CameraPage` the whole time and genuinely could not see the stethoscope no matter how many container rebuilds or cache clears they tried — the JS bundle did contain the `camera.diagnose` strings (they come from `EmbeddedCameraViewer`), but that component never renders in window mode. Switching to overlay mode made it appear instantly, exactly as the reporter found. **Fix**: ported the diagnostic into `CameraPage.tsx` — the `Stethoscope` control-bar button (between **Refresh** and **Fullscreen**, matching the embedded viewer), a **Diagnose** button next to **Retry** in the `streamError` block, and the `CameraDiagnoseModal` render. No new i18n keys — `camera.diagnose.*` already exist in all 9 locales. The backend per-model camera-profile fix from the same issue is view-mode-agnostic and already applied; this only makes the diagnostic reachable in the default window mode. Frontend build clean.
|
||||
- **A backend restart mid-print no longer duplicates the job in the archive (#1485, reported by @pwostran)** — When the server running Bambuddy restarted during an active print, the running job was duplicated in the archive — and deleting the duplicate didn't help: every subsequent restart while the print was still running spawned a fresh one. Both support bundles confirmed it: `WARNING Found stale 'printing' archive 3 (age: 9:46:23), marking as cancelled and creating new archive` → `Created archive 4`. On reconnect `on_print_start` fires (Bambuddy sees the printer running) and tries to re-attach to the existing archive in `main.py`. The reliable match is by `subtask_id`; the fallback is a name match plus — and this was the bug — a **4-hour staleness heuristic**: a name-matched `printing` archive older than 4h was assumed dead, marked `cancelled`, and a new archive created. Bambu prints routinely run far longer than 4h, so a genuine long print's *live* archive was destroyed and duplicated on every restart. **Two root causes, both fixed.** **(1) Queue/scheduled archives never persisted a restart-stable `subtask_id`.** Bambuddy mints a per-job id (`project_id`/`subtask_id`/`task_id`) inside `start_print` when it sends the `project_file` command, and the printer echoes it back — but often not within the ~10s before `on_print_start` first fires, so the expected-print branch's `if subtask_id and not archive.subtask_id` write got an empty value and the archive was left with no id. A later restart then had nothing to match on and fell through to the fragile name path. Fix: `BambuMQTTClient.start_print` now records the minted id on `last_dispatch_subtask_id`, and `on_print_start` falls back to it when the printer hasn't echoed `subtask_id` yet — so every dispatched archive persists a stable id and a restart resumes it by id, age-independent. **(2) The 4-hour cutoff itself.** Replaced with a progress-aware check: when a name-matched `printing` archive is found on restart, the printer's *current* reported progress decides resume-vs-stale, not wall-clock age. Real progress (or unknown progress — printer offline) always resumes the existing archive. It is only treated as a stale leftover when the printer clearly shows a *different, freshly-started* print — under 1% progress on an archive more than 2h old, a state a real in-progress print is never in. The arbitrary 4h constant is gone. **Net effect**: a restart mid-print resumes the existing archive (`started_at`, energy, timelapse intact) instead of ever cancelling it and creating a duplicate. **Tests**: 2 new in `test_bambu_mqtt.py` (`start_print` records `last_dispatch_subtask_id`, and updates it per submission); new `TestStaleVsResume` in `test_subtask_archive_resume.py` — 6 cases pinning the progress-aware decision (long print mid-run resumes; barely-started long print resumes; ~0% + old archive is stale; ~0% + young archive resumes; unknown progress never cancels; the sub-1%/2h boundary). 472 print-start / MQTT / scheduler / dispatch tests green; backend ruff clean.
|
||||
- **File Manager no longer polls the printer over FTPS every 30 seconds while open (#1480, reported by @OscarsWorldTech)** — The reporter's P1S churned through MQTT disconnect/reconnect cycles and timelapse downloads silently failed. The support bundle showed the real picture: during the churn windows, MQTT (`Connection stale - no message for 60.2s`), FTPS (`_ssl.c:1015: The handshake operation timed out`) and the camera all timed out *together* and recovered together — the P1S's embedded controller saturating, not a network fault (wifi -44 dBm, Docker host networking). A visible contributor on Bambuddy's side: `FileManagerModal.tsx` ran its `getPrinterFiles` query with `refetchInterval: 30000`, so every 30 s while the File Manager modal sat open it opened a *fresh* FTPS connection — full TLS handshake — to re-list the current directory. A printer's file list doesn't change on its own; it only changes on upload / delete (the modal's mutations already `invalidateQueries`) or when a print finishes. The blind 30 s poll was pure load, and on a fragile controller like the P1S it was enough to tip MQTT and FTP into the timeouts above. **Fix**: the `refetchInterval` is removed. The listing still refreshes on modal open, on directory / tab change (the path is in the query key), after every upload / delete, and via the existing manual Refresh button — so nothing stops updating, the printer just isn't hammered. Reduces steady-state FTPS connection load while the modal is open from one handshake every 30 s to zero. 19 FileManagerModal tests green; frontend build clean.
|
||||
- **STL thumbnail generation failures now log a full traceback** — Surfaced by the #1480 support bundle: every STL in the reporter's library failed thumbnail generation with `unsupported operand type(s) for /: 'str' and 'str'`, but `generate_stl_thumbnail`'s `except` handler logged only the bare exception message — no traceback, no line number. The fault could not be reproduced from a clean STL across path shapes (`#` and spaces in the path), `str` vs `Path` arguments, or large meshes that exercise `simplify_quadric_decimation`, so it is data- or environment-specific and the message alone is not enough to locate it. `stl_thumbnail.py` now passes `exc_info=True` on that warning, so the next support bundle carries the traceback and the exact failing line. No behaviour change to thumbnail generation itself.
|
||||
|
||||
@@ -125,6 +125,17 @@ describe('CameraPage', () => {
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the camera diagnostic (stethoscope) button in the control bar (#1395)', async () => {
|
||||
// The diagnostic shipped wired into the embedded viewer only; window mode
|
||||
// (this page) was missing it. The control-bar button must be present here.
|
||||
renderCameraPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTitle('Diagnose')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream token handling (#979)', () => {
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut, Stethoscope } from 'lucide-react';
|
||||
import { api, getAuthToken, getStreamToken, withStreamToken } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useStreamTokenSync } from '../hooks/useCameraStreamToken';
|
||||
import { ChamberLight } from '../components/icons/ChamberLight';
|
||||
import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
|
||||
import { CameraDiagnoseModal } from '../components/CameraDiagnoseModal';
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
|
||||
@@ -40,6 +41,7 @@ export function CameraPage() {
|
||||
|
||||
const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
|
||||
const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
|
||||
const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
|
||||
const [streamError, setStreamError] = useState(false);
|
||||
const [streamLoading, setStreamLoading] = useState(true);
|
||||
const [imageKey, setImageKey] = useState(Date.now());
|
||||
@@ -679,6 +681,13 @@ export function CameraPage() {
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDiagnoseModal(true)}
|
||||
className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
|
||||
title={t('camera.diagnose.button')}
|
||||
>
|
||||
<Stethoscope className="w-4 h-4 text-bambu-gray" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
|
||||
@@ -741,12 +750,20 @@ export function CameraPage() {
|
||||
<p className="text-xs text-bambu-gray mb-4 max-w-md">
|
||||
{t('camera.cameraUnavailableDesc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
|
||||
>
|
||||
{t('camera.retry')}
|
||||
</button>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
|
||||
>
|
||||
{t('camera.retry')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDiagnoseModal(true)}
|
||||
className="px-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white rounded transition-colors"
|
||||
>
|
||||
{t('camera.diagnose.button')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -802,6 +819,14 @@ export function CameraPage() {
|
||||
isOpen={showSkipObjectsModal}
|
||||
onClose={() => setShowSkipObjectsModal(false)}
|
||||
/>
|
||||
{/* Camera diagnostic modal — stethoscope icon + error-state Diagnose button (#1395) */}
|
||||
{showDiagnoseModal && (
|
||||
<CameraDiagnoseModal
|
||||
printerId={id}
|
||||
printerName={printer?.name || null}
|
||||
onClose={() => setShowDiagnoseModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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-B7aP7po9.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DuJrYPHG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CBuiHfeb.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user