import { useState, useCallback, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
import { FileText, Wand2, Zap } from 'lucide-react';
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
function formatDateTime(iso: string | null): string {
if (!iso) return '-';
try {
const d = new Date(iso);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch {
return '-';
}
}
const BLANK_OPTIONS = [
{ label: 'Off', value: 0 },
{ label: '1m', value: 60 },
{ label: '2m', value: 120 },
{ label: '5m', value: 300 },
{ label: '10m', value: 600 },
{ label: '30m', value: 1800 },
];
// --- Device Tab ---
function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
const { t } = useTranslation();
const [diagnosticOpen, setDiagnosticOpen] = useState<'nfc' | 'scale' | 'read_tag' | null>(null);
const [backendUrl, setBackendUrl] = useState('');
const [apiToken, setApiToken] = useState('');
const [systemBusy, setSystemBusy] = useState(false);
const [systemMsg, setSystemMsg] = useState<{ type: 'ok' | 'error'; text: string } | null>(null);
useEffect(() => {
if (!backendUrl && device.backend_url) {
setBackendUrl(device.backend_url);
}
}, [device.backend_url, backendUrl]);
const saveConfig = async () => {
if (!backendUrl.trim()) {
setSystemMsg({ type: 'error', text: t('spoolbuddy.settings.systemFieldsRequired', 'Backend URL is required.') });
return;
}
setSystemBusy(true);
setSystemMsg(null);
try {
await spoolbuddyApi.updateSystemConfig(
device.device_id,
backendUrl.trim(),
apiToken.trim() || undefined
);
setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemQueued', 'Config queued.') });
} catch (e) {
setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') });
} finally {
setSystemBusy(false);
}
};
return (
{/* About */}
Part of Bambuddy
github.com/maziggy/bambuddy
{/* NFC Reader + Device Info side by side */}
{/* NFC Reader */}
{t('spoolbuddy.settings.nfcReader', 'NFC Reader')}
{t('spoolbuddy.settings.type', 'Type')}
{device.nfc_reader_type || 'N/A'}
{t('spoolbuddy.settings.connection', 'Connection')}
{device.nfc_connection || 'N/A'}
{t('spoolbuddy.status.status', 'Status')}
{device.nfc_ok
? t('spoolbuddy.status.nfcReady', 'Ready')
: device.nfc_reader_type
? t('common.error', 'Error')
: t('spoolbuddy.settings.notConnected', 'N/A')}
{/* Device Info */}
{t('spoolbuddy.settings.deviceInfo', 'Device Info')}
{t('spoolbuddy.settings.hostname', 'Host')}
{device.hostname}
IP
{device.ip_address}
{t('spoolbuddy.settings.uptime', 'Uptime')}
{formatUptime(device.uptime_s)}
{t('spoolbuddy.status.status', 'Status')}
{device.online ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
{/* Device ID (full width, below cards) */}
Device ID
{device.device_id}
{/* Backend/Auth Config */}
{t('spoolbuddy.settings.systemConfig', 'Backend & Auth')}
{t('spoolbuddy.settings.backendUrl', 'Bambuddy Backend URL')}
setBackendUrl(e.target.value)}
placeholder="http://192.168.1.100:5000"
className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm"
/>
{t('spoolbuddy.settings.apiToken', 'API Token')}
setApiToken(e.target.value)}
placeholder={t('spoolbuddy.settings.apiTokenPlaceholder', 'Enter API token')}
className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm"
/>
{t('spoolbuddy.settings.saveConfig', 'Save Config')}
{systemMsg && (
{systemMsg.text}
)}
{/* Diagnostic Buttons */}
{/* NFC Diagnostic Button */}
setDiagnosticOpen('nfc')}
className="w-full bg-blue-700 hover:bg-blue-600 transition-colors rounded-lg p-3 text-left"
>
{t('spoolbuddy.settings.nfcDiagnostic', 'NFC Diagnostic')}
{t('spoolbuddy.settings.testNfc', 'Test reader')}
{/* Scale Diagnostic Button */}
setDiagnosticOpen('scale')}
className="w-full bg-yellow-700 hover:bg-yellow-600 transition-colors rounded-lg p-3 text-left"
>
{t('spoolbuddy.settings.scaleDiagnostic', 'Scale Diagnostic')}
{t('spoolbuddy.settings.testScale', 'Test accuracy')}
{/* Read Tag Diagnostic Button */}
setDiagnosticOpen('read_tag')}
className="w-full bg-emerald-700 hover:bg-emerald-600 transition-colors rounded-lg p-3 text-left"
>
{t('spoolbuddy.settings.readTagDiagnostic', 'Read Tag Diagnostic')}
{t('spoolbuddy.settings.testReadTag', 'Run read_tag.py')}
{/* Diagnostic Modal */}
{diagnosticOpen && device && (
setDiagnosticOpen(null)}
/>
)}
);
}
// --- Display Tab ---
function DisplayTab({ device, onBrightnessChange, onBlankTimeoutChange }: {
device: SpoolBuddyDevice;
onBrightnessChange: (value: number) => void;
onBlankTimeoutChange: (value: number) => void;
}) {
const { t } = useTranslation();
const [brightness, setBrightness] = useState(device.display_brightness);
const [blankTimeout, setBlankTimeout] = useState(device.display_blank_timeout);
const [saved, setSaved] = useState(false);
const debounceRef = useRef>(undefined);
const savedTimerRef = useRef>(undefined);
// Sync local state when device data updates from server
useEffect(() => {
setBrightness(device.display_brightness);
setBlankTimeout(device.display_blank_timeout);
}, [device.display_brightness, device.display_blank_timeout]);
const showSaved = useCallback(() => {
setSaved(true);
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
savedTimerRef.current = setTimeout(() => setSaved(false), 1500);
}, []);
const sendDisplayUpdate = useCallback((b: number, bt: number) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
spoolbuddyApi.updateDisplay(device.device_id, b, bt)
.then(() => showSaved())
.catch((e) => console.error('Failed to update display:', e));
}, 300);
}, [device.device_id, showSaved]);
const handleBrightnessChange = (value: number) => {
setBrightness(value);
onBrightnessChange(value); // Instant layout update
sendDisplayUpdate(value, blankTimeout);
};
const handleBlankTimeoutChange = (value: number) => {
setBlankTimeout(value);
onBlankTimeoutChange(value); // Instant layout update
sendDisplayUpdate(brightness, value);
};
return (
{/* Brightness */}
{t('spoolbuddy.settings.brightness', 'Brightness')}
{saved && (
{t('spoolbuddy.settings.saved', 'Saved')}
)}
{!device.has_backlight && (
{t('spoolbuddy.settings.noBacklight', 'No DSI backlight detected. Brightness control requires a DSI display.')}
)}
{/* Screen blank timeout */}
{t('spoolbuddy.settings.screenBlank', 'Screen Blank Timeout')}
{t('spoolbuddy.settings.screenBlankDesc', 'Screen turns off after inactivity. Touch to wake.')}
{BLANK_OPTIONS.map((opt) => (
handleBlankTimeoutChange(opt.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors min-h-[40px] ${
blankTimeout === opt.value
? 'bg-green-600 text-white'
: 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
}`}
>
{opt.label}
))}
{t('spoolbuddy.settings.displayNote', 'Brightness is applied as a software filter.')}
);
}
// --- Scale Tab ---
function StepIndicator({ step, labels }: { step: 'tare' | 'weight'; labels: { tare: string; weight: string } }) {
return (
{/* Step 1 circle */}
{step === 'weight' ? (
) : '1'}
{labels.tare}
{/* Connector line */}
{/* Step 2 circle */}
2
{labels.weight}
);
}
function ScaleTab({ device, weight, weightStable, rawAdc }: {
device: SpoolBuddyDevice;
weight: number | null;
weightStable: boolean;
rawAdc: number | null;
}) {
const { t } = useTranslation();
const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');
const [knownWeight, setKnownWeight] = useState('500');
const [tareRawAdc, setTareRawAdc] = useState(null);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ type: 'ok' | 'error'; msg: string } | null>(null);
const numpadPress = (key: string) => {
if (key === 'backspace') {
setKnownWeight((v) => v.slice(0, -1) || '');
} else if (key === '.' && !knownWeight.includes('.')) {
setKnownWeight((v) => v + '.');
} else if (key >= '0' && key <= '9') {
setKnownWeight((v) => (v === '0' ? key : v + key));
}
};
const handleTare = async () => {
setBusy(true);
setStatus(null);
try {
await spoolbuddyApi.tare(device.device_id);
setStatus({ type: 'ok', msg: t('spoolbuddy.settings.tareSet', 'Tare command sent. Waiting for device...') });
} catch {
setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') });
} finally {
setBusy(false);
}
};
const handleCalStep = async () => {
if (calStep === 'tare') {
setBusy(true);
setStatus(null);
try {
setTareRawAdc(rawAdc);
await spoolbuddyApi.tare(device.device_id);
setStatus({ type: 'ok', msg: t('spoolbuddy.settings.zeroSet', 'Zero point set. Place known weight on scale.') });
setCalStep('weight');
} catch {
setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') });
} finally {
setBusy(false);
}
} else if (calStep === 'weight') {
const weightNum = parseFloat(knownWeight);
if (rawAdc === null || !weightNum || weightNum <= 0) return;
setBusy(true);
setStatus(null);
try {
await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined);
setStatus({ type: 'ok', msg: t('spoolbuddy.settings.calibrationDone', 'Calibration complete!') });
setCalStep('idle');
} catch {
setStatus({ type: 'error', msg: t('spoolbuddy.settings.calibrationFailed', 'Calibration failed') });
} finally {
setBusy(false);
}
}
};
// --- Idle state: weight card + buttons ---
if (calStep === 'idle') {
return (
{/* Weight + info card */}
{weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
{t('spoolbuddy.settings.tareOffset', 'Tare')}: {device.tare_offset}
·
{t('spoolbuddy.settings.calFactor', 'Factor')}: {device.calibration_factor.toFixed(2)}
{device.last_calibrated_at && (
{t('spoolbuddy.settings.lastCalibrated', 'Last calibrated')}: {formatDateTime(device.last_calibrated_at)}
)}
{/* Status message */}
{status && (
{status.msg}
)}
{/* Action buttons */}
{busy && (
)}
{t('spoolbuddy.weight.tare', 'Tare')}
{ setCalStep('tare'); setStatus(null); }}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
>
{t('spoolbuddy.weight.calibrate', 'Calibrate')}
);
}
// --- Calibration wizard: step indicator left + content right ---
return (
{/* Left: step indicator */}
{/* Right: content */}
{/* Live weight bar */}
{weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
{weightStable ? t('spoolbuddy.settings.stable', 'Stable') : t('spoolbuddy.settings.settling', 'Settling...')}
{/* Status message */}
{status && (
{status.msg}
)}
{/* Step content */}
{calStep === 'tare' ? (
{t('spoolbuddy.settings.calStep1', 'Remove all items from the scale and press Set Zero.')}
) : (
<>
{t('spoolbuddy.settings.knownWeight', 'Known weight')}
{knownWeight || '0'}g
{['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
numpadPress(key)}
className={`rounded text-lg font-medium transition-colors h-[48px] active:scale-95 ${
key === 'backspace'
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
: 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'
}`}
>
{key === 'backspace' ? '\u232B' : key}
))}
>
)}
{/* Action buttons */}
{ setCalStep('idle'); setStatus(null); }}
className="flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors h-[40px]"
>
{t('common.cancel', 'Cancel')}
{busy && (
)}
{calStep === 'tare' ? t('spoolbuddy.settings.setZero', 'Set Zero') : t('spoolbuddy.settings.calibrateNow', 'Calibrate')}
);
}
// --- Updates Tab ---
function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
const { t } = useTranslation();
const [busy, setBusy] = useState<'checking' | 'applying' | null>(null);
const [error, setError] = useState(null);
const [sshExpanded, setSSHExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const isUpdating = device.update_status === 'pending' || device.update_status === 'updating';
// When applying succeeds and device picks up the update, keep showing busy
useEffect(() => {
if (isUpdating && busy === 'applying') {
setBusy(null); // device has picked it up, isUpdating takes over the UI
}
}, [isUpdating, busy]);
// Reload the page when daemon comes back online after an update
useEffect(() => {
const handleOnline = () => {
if (isUpdating) {
// Daemon re-registered — reload to get fresh version + state
setTimeout(() => window.location.reload(), 1000);
}
};
window.addEventListener('spoolbuddy-online', handleOnline);
return () => window.removeEventListener('spoolbuddy-online', handleOnline);
}, [isUpdating]);
const { data: updateResult, refetch } = useQuery({
queryKey: ['spoolbuddy-update-check', device.device_id],
queryFn: () => spoolbuddyApi.checkDaemonUpdate(device.device_id),
staleTime: 0,
});
const { data: sshKeyData } = useQuery({
queryKey: ['spoolbuddy-ssh-key'],
queryFn: () => spoolbuddyApi.getSSHPublicKey(),
enabled: sshExpanded,
staleTime: Infinity,
});
const checkForUpdates = async () => {
setBusy('checking');
setError(null);
try {
await refetch();
} finally {
setBusy(null);
}
};
const applyUpdate = async () => {
setBusy('applying');
setError(null);
try {
await spoolbuddyApi.triggerUpdate(device.device_id);
// Don't clear busy — keep showing spinner until isUpdating takes over or timeout
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to trigger update');
setBusy(null);
}
};
const showSpinner = busy != null || isUpdating;
const copyKey = () => {
if (sshKeyData?.public_key) {
navigator.clipboard.writeText(sshKeyData.public_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const displayVersion = device.firmware_version
|| (updateResult?.current_version && updateResult.current_version !== '0.0.0' ? updateResult.current_version : null);
return (
{/* Version + Update status + Check — single card */}
{/* Version row */}
{t('spoolbuddy.settings.currentVersion', 'Current Version')}
{displayVersion || (
{t('spoolbuddy.settings.versionPending', 'Waiting for daemon...')}
)}
{/* Status / progress row */}
{showSpinner ? (
{busy === 'checking' ? t('spoolbuddy.settings.checking', 'Checking for updates...')
: device.update_message || t('spoolbuddy.settings.updateWaiting', 'Updating...')}
) : device.update_status === 'error' ? (
{device.update_message || t('spoolbuddy.settings.updateFailed', 'Update failed')}
) : error ? (
{error}
) : updateResult?.update_available ? (
{t('spoolbuddy.settings.updateAvailable', 'Update available')}: {displayVersion} → {updateResult.latest_version}
) : null}
{/* Action buttons */}
{!showSpinner && (
updateResult?.update_available ? (
{!device.online
? t('spoolbuddy.settings.deviceOffline', 'Device Offline')
: t('spoolbuddy.settings.applyUpdate', 'Apply Update')}
) : (
{t('spoolbuddy.settings.checkUpdates', 'Check for Updates')}
{t('spoolbuddy.settings.forceUpdate', 'Force Update')}
)
)}
{/* SSH Setup — collapsible */}
setSSHExpanded(!sshExpanded)}
className="w-full flex justify-between items-center text-xs"
>
{t('spoolbuddy.settings.sshSetup', 'SSH Setup')}
{sshExpanded && (
{t('spoolbuddy.settings.sshDescription', 'SSH key is deployed automatically. For manual setup, add this key to ~/.ssh/authorized_keys on the device.')}
{sshKeyData?.public_key ? (
{sshKeyData.public_key}
{copied ? t('common.copied', 'Copied!') : t('common.copy', 'Copy')}
) : (
{t('spoolbuddy.settings.sshKeyLoading', 'Loading...')}
)}
)}
);
}
// --- Main Settings Page ---
type SettingsTab = 'device' | 'display' | 'scale' | 'updates';
export function SpoolBuddySettingsPage() {
const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('device');
const { data: devices = [] } = useQuery({
queryKey: ['spoolbuddy-devices'],
queryFn: () => spoolbuddyApi.getDevices(),
refetchInterval: 10000,
});
// Use first device (most common setup) or find one matching current state
const device = sbState.deviceId
? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]
: devices[0];
const tabs: { id: SettingsTab; label: string }[] = [
{ id: 'device', label: t('spoolbuddy.settings.tabDevice', 'Device') },
{ id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
{ id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') },
{ id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') },
];
return (
{t('spoolbuddy.nav.settings', 'Settings')}
{/* Tab bar */}
{tabs.map((tab) => (
setActiveTab(tab.id)}
className={`flex-1 px-2 py-2 rounded-md text-sm font-medium transition-colors min-h-[36px] ${
activeTab === tab.id
? 'bg-zinc-700 text-zinc-100'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
{tab.label}
))}
{/* Content */}
{!device ? (
{t('spoolbuddy.settings.noDevice', 'No SpoolBuddy device found')}
) : (
<>
{activeTab === 'device' &&
}
{activeTab === 'display' && (
)}
{activeTab === 'scale' && (
)}
{activeTab === 'updates' &&
}
>
)}
);
}