mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
+4
-1
@@ -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.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user