Fix SpoolBuddy add-to-inventory, dashboard crash, and kiosk display issues

- Round scale weight to integer before sending to backend (Pydantic
    rejects non-whole floats for int fields), move modal close to finally
    block, add error toast with actual API message
  - Fix null-field crash in SpoolInfoCard prop construction: pick one
    source object instead of per-field ?? fallbacks that crash when
    displayedSpool has null subtype/brand/rgba and matchedSpool is null
  - Add React ErrorBoundary to App so crashes show error instead of
    black screen
  - Remove --max-old-space-size=128 and --enable-low-end-device-mode
    from kiosk Chromium flags (crashed renderer/display)
  - Append kiosk flags to Pi GPU defaults instead of resetting them
  - Add wlr-randr keep-alive and screenBlankTimeout=0 to prevent
    display blanking on labwc 0.9.x
  - Fix tests: add ToastProvider to Dashboard test wrapper, update
    StatusBar tests for removed animate-pulse class
This commit is contained in:
maziggy
2026-03-28 10:51:51 +01:00
parent 208793c934
commit a4b927f2d2
8 changed files with 138 additions and 89 deletions
+4 -1
View File
@@ -6,6 +6,9 @@ All notable changes to Bambuddy will be documented in this file.
### Fixed
- **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. Reported by @NoahTingey.
- **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.
- **SpoolBuddy Dashboard Crash on Null Spool Fields** — Viewing a spool with null `subtype`, `brand`, `rgba`, or `color_name` on the SpoolBuddy dashboard crashed the UI (black screen). The spool prop construction used `displayedSpool?.subtype ?? sbState.matchedSpool!.subtype` — when the field was `null`, the `??` operator fell through to `sbState.matchedSpool` which could also be null, causing a TypeError. Fixed by picking one source object instead of mixing per-field fallbacks. Added a global React error boundary so future crashes show the error instead of a black screen.
- **SpoolBuddy Kiosk Display Blanking and Crashes** — The kiosk Chromium flags added in 0.2.2.2 caused display instability: `--js-flags=--max-old-space-size=128` crashed the V8 renderer when heap exceeded 128 MB, `--enable-low-end-device-mode` aggressively killed GPU rendering surfaces, and resetting `CHROMIUM_FLAGS` discarded the Pi's GPU defaults (`--enable-gpu-rasterization`, ANGLE/GLES) creating an unstable mixed CPU/GPU rendering path. Fixed by removing both flags, appending kiosk flags to Pi defaults instead of replacing them, adding a `wlr-randr` keep-alive loop to prevent display blanking, and adding `<screenBlankTimeout>0</screenBlankTimeout>` to the labwc config.
## [0.2.2.2] - 2026-03-27
@@ -17,7 +20,7 @@ All notable changes to Bambuddy will be documented in this file.
- **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, gcode, and image files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.
### Improved
- **SpoolBuddy Kiosk Performance Optimizations** — Reduced idle CPU load on Raspberry Pi from ~3.3 to ~0.9. Frontend: replaced expensive CSS animations on the idle dashboard (`animate-ping` with scale transforms, `blur-2xl` glow, continuous `animate-pulse` on status dots) with static elements and a slow color-cycling spool (5s interval). Chromium: added `--disable-extensions`, `--disable-background-timer-throttling`, `--memory-pressure-off`, `--disable-renderer-backgrounding`, `--disable-breakpad`, and `--js-flags=--max-old-space-size=128` to `/etc/chromium.d/spoolbuddy-kiosk`. WebSocket: SpoolBuddy Dashboard and Layout pages now use React Query `select` to extract only `connected` status from printer queries, so temperature/fan/progress updates no longer trigger re-renders on every MQTT tick. Services: stripped services are now masked (not just disabled) to prevent socket/dbus reactivation; user-level services (xdg-desktop-portal, mpris-proxy, pipewire, etc.) are masked globally via `/etc/systemd/user/` overrides instead of unreliable `su -l systemctl --user`. Removed chromium and upower from `strip_packages` since the kiosk needs them — they were being uninstalled then immediately reinstalled on every run.
- **SpoolBuddy Kiosk Performance Optimizations** — Reduced idle CPU load on Raspberry Pi from ~3.3 to ~0.9. Frontend: replaced expensive CSS animations on the idle dashboard (`animate-ping` with scale transforms, `blur-2xl` glow, continuous `animate-pulse` on status dots) with static elements and a slow color-cycling spool (5s interval). Chromium: added `--disable-extensions`, `--disable-background-timer-throttling`, `--disable-renderer-backgrounding`, and `--disable-crash-reporter` to `/etc/chromium.d/spoolbuddy-kiosk`. WebSocket: SpoolBuddy Dashboard and Layout pages now use React Query `select` to extract only `connected` status from printer queries, so temperature/fan/progress updates no longer trigger re-renders on every MQTT tick. Services: stripped services are now masked (not just disabled) to prevent socket/dbus reactivation; user-level services (xdg-desktop-portal, mpris-proxy, pipewire, etc.) are masked globally via `/etc/systemd/user/` overrides instead of unreliable `su -l systemctl --user`. Removed chromium and upower from `strip_packages` since the kiosk needs them — they were being uninstalled then immediately reinstalled on every run.
- **SpoolBuddy AMS Slot Action Picker** — Clicking an AMS slot on the SpoolBuddy AMS page now shows a picker with contextual actions: Configure AMS Slot (set filament preset, K-profile, color), and either Assign Spool / Link to Spoolman (when no spool is mapped) or Unassign / Unlink (when one is). Works with both internal inventory and Spoolman. Previously the slot click went straight to the configure modal with no way to manage spool assignments.
- **Unassign Button in Edit Spool Modal** — The edit spool modal now has an "Unassign" button next to "Delete Tag" that removes the spool's AMS slot assignment, clearing the location column in the inventory table.
- **SpoolBuddy Settings Device Tab No Longer Scrolls** — Removed the branding card, folded Device ID into the Device Info card, placed Backend/Auth config and diagnostic buttons side by side in a 2-column layout, removed the redundant online/offline status row from Device Info, and tightened spacing throughout. The Device tab now fits on the small SpoolBuddy touchscreen without scrolling.
+37
View File
@@ -1,3 +1,4 @@
import { Component, type ReactNode, type ErrorInfo } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Layout } from './components/Layout';
@@ -32,6 +33,40 @@ import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPag
import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null; errorInfo: ErrorInfo | null }> {
state = { error: null as Error | null, errorInfo: null as ErrorInfo | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ errorInfo });
console.error('React crash:', error, errorInfo);
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 24, color: '#ef4444', backgroundColor: '#18181b', minHeight: '100vh', fontFamily: 'monospace' }}>
<h1 style={{ fontSize: 20, marginBottom: 12 }}>UI Crash</h1>
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 14 }}>{this.state.error.message}</pre>
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, color: '#a1a1aa', marginTop: 12 }}>
{this.state.error.stack}
</pre>
<button
onClick={() => { this.setState({ error: null, errorInfo: null }); }}
style={{ marginTop: 16, padding: '8px 16px', backgroundColor: '#3b82f6', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}
>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -109,6 +144,7 @@ function SetupRoute({ children }: { children: React.ReactNode }) {
function App() {
return (
<ErrorBoundary>
<ThemeProvider>
<ToastProvider>
<QueryClientProvider client={queryClient}>
@@ -165,6 +201,7 @@ function App() {
</QueryClientProvider>
</ToastProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
@@ -25,7 +25,7 @@ describe('SpoolBuddyStatusBar', () => {
it('uses green status LED when no alert', () => {
const { container } = render(<SpoolBuddyStatusBar />);
const led = container.querySelector('.rounded-full.animate-pulse');
const led = container.querySelector('.rounded-full');
expect(led!.className).toContain('bg-bambu-green');
});
@@ -34,7 +34,7 @@ describe('SpoolBuddyStatusBar', () => {
<SpoolBuddyStatusBar alert={{ type: 'warning', message: 'Low filament' }} />
);
expect(screen.getByText('Low filament')).toBeDefined();
const led = container.querySelector('.rounded-full.animate-pulse');
const led = container.querySelector('.rounded-full');
expect(led!.className).toContain('bg-amber-500');
// Border should also be amber
const bar = container.firstElementChild as HTMLElement;
@@ -46,7 +46,7 @@ describe('SpoolBuddyStatusBar', () => {
<SpoolBuddyStatusBar alert={{ type: 'error', message: 'Connection lost' }} />
);
expect(screen.getByText('Connection lost')).toBeDefined();
const led = container.querySelector('.rounded-full.animate-pulse');
const led = container.querySelector('.rounded-full');
expect(led!.className).toContain('bg-red-500');
const bar = container.firstElementChild as HTMLElement;
expect(bar.className).toContain('border-red-500');
@@ -57,7 +57,7 @@ describe('SpoolBuddyStatusBar', () => {
<SpoolBuddyStatusBar alert={{ type: 'info', message: 'Update available' }} />
);
expect(screen.getByText('Update available')).toBeDefined();
const led = container.querySelector('.rounded-full.animate-pulse');
const led = container.querySelector('.rounded-full');
expect(led!.className).toContain('bg-bambu-green');
});
});
@@ -13,6 +13,7 @@ import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
import { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';
import { ToastProvider } from '../../contexts/ToastContext';
vi.mock('../../api/client', () => ({
api: {
@@ -69,15 +70,17 @@ function renderPage(overrides: Partial<typeof mockOutletContext['sbState']> = {}
}
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/spoolbuddy']}>
<Routes>
<Route element={<Wrapper />}>
<Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>
<ToastProvider>
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/spoolbuddy']}>
<Routes>
<Route element={<Wrapper />}>
<Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ToastProvider>
);
}
@@ -4,6 +4,7 @@ import { useQuery, useQueries } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
import { useToast } from '../../contexts/ToastContext';
import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
@@ -117,6 +118,7 @@ function DeviceOfflineState() {
export function SpoolBuddyDashboard() {
const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
const { t } = useTranslation();
const { showToast } = useToast();
// Fetch spools for stats, tag lookup, and untagged list
const { data: spools = [], refetch: refetchSpools } = useQuery({
@@ -253,15 +255,17 @@ export function SpoolBuddyDashboard() {
data_origin: 'spoolbuddy',
tag_type: 'generic',
cost_per_kg: null,
last_scale_weight: weight,
last_scale_weight: weight !== null ? Math.round(weight) : null,
last_weighed_at: weight !== null ? new Date().toISOString() : null,
});
setShowQuickAddModal(false);
refetchSpools();
} catch (e) {
console.error('Failed to quick-add spool:', e);
const msg = e instanceof Error ? e.message : String(e);
console.error('Failed to quick-add spool:', msg);
showToast(msg || t('spoolbuddy.errors.quickAddFailed', 'Failed to add spool'), 'error');
} finally {
setShowQuickAddModal(false);
setQuickAddBusy(false);
refetchSpools();
}
};
@@ -378,18 +382,21 @@ export function SpoolBuddyDashboard() {
<DeviceOfflineState />
) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
<SpoolInfoCard
spool={{
id: displayedSpool?.id ?? sbState.matchedSpool!.id,
tag_uid: displayedTagId,
material: displayedSpool?.material ?? sbState.matchedSpool!.material,
subtype: displayedSpool?.subtype ?? sbState.matchedSpool!.subtype,
color_name: displayedSpool?.color_name ?? sbState.matchedSpool!.color_name,
rgba: displayedSpool?.rgba ?? sbState.matchedSpool!.rgba,
brand: displayedSpool?.brand ?? sbState.matchedSpool!.brand,
label_weight: displayedSpool?.label_weight ?? sbState.matchedSpool!.label_weight,
core_weight: displayedSpool?.core_weight ?? sbState.matchedSpool!.core_weight,
weight_used: displayedSpool?.weight_used ?? sbState.matchedSpool!.weight_used,
}}
spool={(() => {
const s = displayedSpool ?? sbState.matchedSpool!;
return {
id: s.id,
tag_uid: displayedTagId,
material: s.material,
subtype: s.subtype,
color_name: s.color_name,
rgba: s.rgba,
brand: s.brand,
label_weight: s.label_weight,
core_weight: s.core_weight,
weight_used: s.weight_used,
};
})()}
scaleWeight={liveWeight ?? displayedWeight}
onSyncWeight={() => refetchSpools()}
onAssignToAms={() => setShowAssignAmsModal(true)}
+10 -11
View File
@@ -1079,6 +1079,11 @@ EOF
<?xml version="1.0"?>
<labwc_config>
<!-- Disable screen blanking — kiosk must stay on -->
<core>
<screenBlankTimeout>0</screenBlankTimeout>
</core>
<theme>
<name></name>
<cornerRadius>0</cornerRadius>
@@ -1109,22 +1114,13 @@ EOF
# ── Override Debian/RPi Chromium defaults for kiosk performance ──────
cat > /etc/chromium.d/spoolbuddy-kiosk << 'CHROMIUM_EOF'
# SpoolBuddy kiosk: override system defaults for low-end Pi hardware.
# Replaces CHROMIUM_FLAGS entirely — system defaults (gpu-rasterization,
# remote-extensions, pings, media-router) are not needed in kiosk mode.
CHROMIUM_FLAGS="--disable-gpu-rasterization"
# SpoolBuddy kiosk: add kiosk-specific flags on top of Pi defaults.
# Preserves Pi GPU settings (gpu-rasterization, ANGLE/GLES) for stability.
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-smooth-scrolling"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --enable-low-end-device-mode"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-background-networking"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-dev-shm-usage"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-pings"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-default-browser-check"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-extensions"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-background-timer-throttling"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --memory-pressure-off"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-renderer-backgrounding"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-crash-reporter"
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --js-flags=--max-old-space-size=128"
CHROMIUM_EOF
success "Chromium kiosk performance flags installed"
@@ -1182,6 +1178,9 @@ EOF
# Force 1024x600 (panel doesn't advertise this natively)
wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
# Prevent display blanking (labwc 0.9.x has no config option for this)
(while true; do sleep 60; wlr-randr --output HDMI-A-1 --on 2>/dev/null; done) &
# Launch Chromium via helper that resolves URL from spoolbuddy/.env
$kiosk_launcher &
EOF
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CnzPkDwq.js"></script>
<script type="module" crossorigin src="/assets/index-CdG7loGy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0h3VoP7.css">
</head>
<body>