Compare commits

..

No commits in common. "a275f7231107ac5fc6b4eea98705de08143f2e06" and "60762d1526a8547d0341a7150378e0ebb61750cb" have entirely different histories.

13 changed files with 933 additions and 692 deletions

View file

@ -1,13 +1,18 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from './stores/useMachineStore'; import { useMachineStore } from './stores/useMachineStore';
import { usePatternStore } from './stores/usePatternStore'; import { usePatternStore } from './stores/usePatternStore';
import { useUIStore } from './stores/useUIStore'; import { useUIStore } from './stores/useUIStore';
import { AppHeader } from './components/AppHeader'; import { FileUpload } from './components/FileUpload';
import { LeftSidebar } from './components/LeftSidebar';
import { PatternCanvas } from './components/PatternCanvas'; import { PatternCanvas } from './components/PatternCanvas';
import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder'; import { ProgressMonitor } from './components/ProgressMonitor';
import { WorkflowStepper } from './components/WorkflowStepper';
import { PatternSummaryCard } from './components/PatternSummaryCard';
import { BluetoothDevicePicker } from './components/BluetoothDevicePicker'; import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
import { getErrorDetails } from './utils/errorCodeHelpers';
import { getStateVisualInfo } from './utils/machineStateHelpers';
import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
import { isBluetoothSupported } from './utils/bluetoothSupport';
import './App.css'; import './App.css';
function App() { function App() {
@ -15,69 +20,442 @@ function App() {
useEffect(() => { useEffect(() => {
document.title = `Respira v${__APP_VERSION__}`; document.title = `Respira v${__APP_VERSION__}`;
}, []); }, []);
// Machine store
// Machine store - for auto-loading cached pattern
const { const {
resumedPattern, isConnected,
machineInfo,
machineStatus,
machineStatusName,
machineError,
patternInfo,
error: machineErrorMessage,
isPairingError,
isCommunicating: isPolling,
resumeFileName, resumeFileName,
resumedPattern,
connect,
disconnect,
} = useMachineStore( } = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
resumedPattern: state.resumedPattern, isConnected: state.isConnected,
machineInfo: state.machineInfo,
machineStatus: state.machineStatus,
machineStatusName: state.machineStatusName,
machineError: state.machineError,
patternInfo: state.patternInfo,
error: state.error,
isPairingError: state.isPairingError,
isCommunicating: state.isCommunicating,
resumeFileName: state.resumeFileName, resumeFileName: state.resumeFileName,
resumedPattern: state.resumedPattern,
connect: state.connect,
disconnect: state.disconnect,
})) }))
); );
// Pattern store - for auto-loading cached pattern // Pattern store
const { const {
pesData, pesData,
patternUploaded,
setPattern, setPattern,
setPatternOffset, setPatternOffset,
setPatternUploaded,
} = usePatternStore( } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
patternUploaded: state.patternUploaded,
setPattern: state.setPattern, setPattern: state.setPattern,
setPatternOffset: state.setPatternOffset, setPatternOffset: state.setPatternOffset,
setPatternUploaded: state.setPatternUploaded,
})) }))
); );
// UI store - for Pyodide initialization // UI store
const { const {
pyodideError,
showErrorPopover,
initializePyodide, initializePyodide,
setErrorPopover,
} = useUIStore( } = useUIStore(
useShallow((state) => ({ useShallow((state) => ({
pyodideError: state.pyodideError,
showErrorPopover: state.showErrorPopover,
initializePyodide: state.initializePyodide, initializePyodide: state.initializePyodide,
setErrorPopover: state.setErrorPopover,
})) }))
); );
const errorPopoverRef = useRef<HTMLDivElement>(null);
const errorButtonRef = useRef<HTMLButtonElement>(null);
// Initialize Pyodide in background on mount (non-blocking thanks to worker) // Initialize Pyodide in background on mount (non-blocking thanks to worker)
useEffect(() => { useEffect(() => {
initializePyodide(); initializePyodide();
}, [initializePyodide]); }, [initializePyodide]);
// Auto-load cached pattern when available // Close error popover when clicking outside
useEffect(() => { useEffect(() => {
if (resumedPattern && !pesData) { const handleClickOutside = (event: MouseEvent) => {
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset); if (
setPattern(resumedPattern.pesData, resumeFileName || ''); errorPopoverRef.current &&
// Restore the cached pattern offset !errorPopoverRef.current.contains(event.target as Node) &&
if (resumedPattern.patternOffset) { errorButtonRef.current &&
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y); !errorButtonRef.current.contains(event.target as Node)
) {
setErrorPopover(false);
} }
};
if (showErrorPopover) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
} }
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]); }, [showErrorPopover, setErrorPopover]);
// Auto-load cached pattern when available
if (resumedPattern && !pesData) {
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
setPattern(resumedPattern.pesData, resumeFileName || '');
// Restore the cached pattern offset
if (resumedPattern.patternOffset) {
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
}
}
// Track pattern uploaded state based on machine status
if (!isConnected) {
if (patternUploaded) {
setPatternUploaded(false);
}
} else {
// Pattern is uploaded if machine has pattern info
const shouldBeUploaded = patternInfo !== null;
if (patternUploaded !== shouldBeUploaded) {
setPatternUploaded(shouldBeUploaded);
}
}
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = {
ready: CheckCircleIcon,
active: BoltIcon,
waiting: PauseCircleIcon,
complete: CheckCircleIcon,
interrupted: PauseCircleIcon,
error: ExclamationTriangleIcon,
};
const StatusIcon = stateIcons[stateVisual.iconName];
return ( return (
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900 overflow-hidden"> <div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900 overflow-hidden">
<AppHeader /> <header className="bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 dark:from-blue-700 dark:via-blue-800 dark:to-blue-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-blue-900/20 dark:border-blue-800/30 flex-shrink-0">
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
{/* Machine Connection Status - Responsive width column */}
<div className="flex items-center gap-3 w-full lg:w-[280px]">
<div className="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shadow-lg shadow-green-400/50" style={{ visibility: isConnected ? 'visible' : 'hidden' }}></div>
<div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1>
{isConnected && machineInfo?.serialNumber && (
<span
className="text-xs text-blue-200 cursor-help"
title={`Serial: ${machineInfo.serialNumber}${
machineInfo.macAddress
? `\nMAC: ${machineInfo.macAddress}`
: ''
}${
machineInfo.totalCount !== undefined
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
: ''
}${
machineInfo.serviceCount !== undefined
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
: ''
}`}
>
{machineInfo.serialNumber}
</span>
)}
{isPolling && (
<ArrowPathIcon className="w-3.5 h-3.5 text-blue-200 animate-spin" title="Auto-refreshing status" />
)}
</div>
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
{isConnected ? (
<>
<button
onClick={disconnect}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-white/10 hover:bg-red-600 text-blue-100 hover:text-white border border-white/20 hover:border-red-600 cursor-pointer transition-all flex-shrink-0"
title="Disconnect from machine"
aria-label="Disconnect from machine"
>
<XMarkIcon className="w-3 h-3" />
Disconnect
</button>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0">
<StatusIcon className="w-3 h-3" />
{machineStatusName}
</span>
</>
) : (
<p className="text-xs text-blue-200">Not Connected</p>
)}
{/* Error indicator - always render to prevent layout shift */}
<div className="relative">
<button
ref={errorButtonRef}
onClick={() => setErrorPopover(!showErrorPopover)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-red-500/90 hover:bg-red-600 text-white border border-red-400 transition-all flex-shrink-0 ${
(machineErrorMessage || pyodideError)
? 'cursor-pointer animate-pulse hover:animate-none'
: 'invisible pointer-events-none'
}`}
title="Click to view error details"
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{(() => {
if (pyodideError) return 'Python Error';
if (isPairingError) return 'Pairing Required';
const errorMsg = machineErrorMessage || '';
// Categorize by error message content
if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
return 'Connection Error';
}
if (errorMsg.toLowerCase().includes('upload')) {
return 'Upload Error';
}
if (errorMsg.toLowerCase().includes('pattern')) {
return 'Pattern Error';
}
if (machineError !== undefined) {
return `Machine Error`;
}
// Default fallback
return 'Error';
})()}
</span>
</button>
{/* Error popover */}
{showErrorPopover && (machineErrorMessage || pyodideError) && (
<div
ref={errorPopoverRef}
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
role="dialog"
aria-label="Error details"
>
{(() => {
const errorDetails = getErrorDetails(machineError);
const isPairingErr = isPairingError;
const errorMsg = pyodideError || machineErrorMessage || '';
const isInfo = isPairingErr || errorDetails?.isInformational;
const bgColor = isInfo
? 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500'
: 'bg-red-50 dark:bg-red-900/95 border-red-600 dark:border-red-500';
const iconColor = isInfo
? 'text-blue-600 dark:text-blue-400'
: 'text-red-600 dark:text-red-400';
const textColor = isInfo
? 'text-blue-900 dark:text-blue-200'
: 'text-red-900 dark:text-red-200';
const descColor = isInfo
? 'text-blue-800 dark:text-blue-300'
: 'text-red-800 dark:text-red-300';
const listColor = isInfo
? 'text-blue-700 dark:text-blue-300'
: 'text-red-700 dark:text-red-300';
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error');
return (
<div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
<div className="flex items-start gap-3">
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1">
<h3 className={`text-base font-semibold ${textColor} mb-2`}>
{title}
</h3>
<p className={`text-sm ${descColor} mb-3`}>
{errorDetails?.description || errorMsg}
</p>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<>
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
{isInfo ? 'Steps:' : 'How to Fix:'}
</h4>
<ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}>
{errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2">{solution}</li>
))}
</ol>
</>
)}
{machineError !== undefined && !errorDetails?.isInformational && (
<p className={`text-xs ${descColor} mt-3 font-mono`}>
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
</p>
)}
</div>
</div>
</div>
);
})()}
</div>
)}
</div>
</div>
</div>
</div>
{/* Workflow Stepper - Flexible width column */}
<div>
<WorkflowStepper />
</div>
</div>
</header>
<div className="flex-1 p-4 sm:p-5 lg:p-6 w-full overflow-y-auto lg:overflow-hidden flex flex-col"> <div className="flex-1 p-4 sm:p-5 lg:p-6 w-full overflow-y-auto lg:overflow-hidden flex flex-col">
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden"> <div className="flex-1 grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
{/* Left Column - Controls */} {/* Left Column - Controls */}
<LeftSidebar /> <div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
{/* Connect Button or Browser Hint - Show when disconnected */}
{!isConnected && (
<>
{isBluetoothSupported() ? (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p>
</div>
</div>
<button
onClick={connect}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
>
Connect to Machine
</button>
</div>
) : (
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg shadow-md border-l-4 border-amber-500 dark:border-amber-600">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-amber-900 dark:text-amber-100 mb-2">Browser Not Supported</h3>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-3">
Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
</p>
<div className="space-y-2">
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">Please try one of these options:</p>
<ul className="text-sm text-amber-800 dark:text-amber-200 space-y-1.5 ml-4 list-disc">
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
<li>
Download the Desktop app from{' '}
<a
href="https://github.com/jhbruhn/respira/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="font-semibold underline hover:text-amber-900 dark:hover:text-amber-50 transition-colors"
>
GitHub Releases
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
)}
</>
)}
{/* Pattern File - Show during upload stage (before pattern is uploaded) */}
{isConnected && !patternUploaded && (
<FileUpload />
)}
{/* Compact Pattern Summary - Show after upload (during sewing stages) */}
{isConnected && patternUploaded && pesData && (
<PatternSummaryCard />
)}
{/* Progress Monitor - Show when pattern is uploaded */}
{isConnected && patternUploaded && (
<div className="lg:flex-1 lg:min-h-0">
<ProgressMonitor />
</div>
)}
</div>
{/* Right Column - Pattern Preview */} {/* Right Column - Pattern Preview */}
<div className="flex flex-col lg:overflow-hidden lg:h-full"> <div className="flex flex-col lg:overflow-hidden lg:h-full">
{pesData ? <PatternCanvas /> : <PatternPreviewPlaceholder />} {pesData ? (
<PatternCanvas />
) : (
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2>
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
{/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10">
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
</div>
<div className="text-center relative z-10">
<div className="relative inline-block mb-6">
<svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
</div>
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
Connect to your machine and choose a PES embroidery file to see your design preview
</p>
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></div>
<span>Drag to Position</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-400 dark:bg-green-500 rounded-full"></div>
<span>Zoom & Pan</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-purple-400 dark:bg-purple-500 rounded-full"></div>
<span>Real-time Preview</span>
</div>
</div>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>

View file

@ -1,207 +0,0 @@
import { useRef, useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from '../stores/useMachineStore';
import { useUIStore } from '../stores/useUIStore';
import { WorkflowStepper } from './WorkflowStepper';
import { ErrorPopover } from './ErrorPopover';
import { getStateVisualInfo } from '../utils/machineStateHelpers';
import {
CheckCircleIcon,
BoltIcon,
PauseCircleIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
export function AppHeader() {
const {
isConnected,
machineInfo,
machineStatus,
machineStatusName,
machineError,
error: machineErrorMessage,
isPairingError,
isCommunicating: isPolling,
disconnect,
} = useMachineStore(
useShallow((state) => ({
isConnected: state.isConnected,
machineInfo: state.machineInfo,
machineStatus: state.machineStatus,
machineStatusName: state.machineStatusName,
machineError: state.machineError,
error: state.error,
isPairingError: state.isPairingError,
isCommunicating: state.isCommunicating,
disconnect: state.disconnect,
}))
);
const {
pyodideError,
showErrorPopover,
setErrorPopover,
} = useUIStore(
useShallow((state) => ({
pyodideError: state.pyodideError,
showErrorPopover: state.showErrorPopover,
setErrorPopover: state.setErrorPopover,
}))
);
const errorPopoverRef = useRef<HTMLDivElement>(null);
const errorButtonRef = useRef<HTMLButtonElement>(null);
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = {
ready: CheckCircleIcon,
active: BoltIcon,
waiting: PauseCircleIcon,
complete: CheckCircleIcon,
interrupted: PauseCircleIcon,
error: ExclamationTriangleIcon,
};
const StatusIcon = stateIcons[stateVisual.iconName];
// Close error popover when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
errorPopoverRef.current &&
!errorPopoverRef.current.contains(event.target as Node) &&
errorButtonRef.current &&
!errorButtonRef.current.contains(event.target as Node)
) {
setErrorPopover(false);
}
};
if (showErrorPopover) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showErrorPopover, setErrorPopover]);
return (
<header className="bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 dark:from-blue-700 dark:via-blue-800 dark:to-blue-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-blue-900/20 dark:border-blue-800/30 flex-shrink-0">
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
{/* Machine Connection Status - Responsive width column */}
<div className="flex items-center gap-3 w-full lg:w-[280px]">
<div className="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shadow-lg shadow-green-400/50" style={{ visibility: isConnected ? 'visible' : 'hidden' }}></div>
<div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1>
{isConnected && machineInfo?.serialNumber && (
<span
className="text-xs text-blue-200 cursor-help"
title={`Serial: ${machineInfo.serialNumber}${
machineInfo.macAddress
? `\nMAC: ${machineInfo.macAddress}`
: ''
}${
machineInfo.totalCount !== undefined
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
: ''
}${
machineInfo.serviceCount !== undefined
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
: ''
}`}
>
{machineInfo.serialNumber}
</span>
)}
{isPolling && (
<ArrowPathIcon className="w-3.5 h-3.5 text-blue-200 animate-spin" title="Auto-refreshing status" />
)}
</div>
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
{isConnected ? (
<>
<button
onClick={disconnect}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-white/10 hover:bg-red-600 text-blue-100 hover:text-white border border-white/20 hover:border-red-600 cursor-pointer transition-all flex-shrink-0"
title="Disconnect from machine"
aria-label="Disconnect from machine"
>
<XMarkIcon className="w-3 h-3" />
Disconnect
</button>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0">
<StatusIcon className="w-3 h-3" />
{machineStatusName}
</span>
</>
) : (
<p className="text-xs text-blue-200">Not Connected</p>
)}
{/* Error indicator - always render to prevent layout shift */}
<div className="relative">
<button
ref={errorButtonRef}
onClick={() => setErrorPopover(!showErrorPopover)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-red-500/90 hover:bg-red-600 text-white border border-red-400 transition-all flex-shrink-0 ${
(machineErrorMessage || pyodideError)
? 'cursor-pointer animate-pulse hover:animate-none'
: 'invisible pointer-events-none'
}`}
title="Click to view error details"
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{(() => {
if (pyodideError) return 'Python Error';
if (isPairingError) return 'Pairing Required';
const errorMsg = machineErrorMessage || '';
// Categorize by error message content
if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
return 'Connection Error';
}
if (errorMsg.toLowerCase().includes('upload')) {
return 'Upload Error';
}
if (errorMsg.toLowerCase().includes('pattern')) {
return 'Pattern Error';
}
if (machineError !== undefined) {
return `Machine Error`;
}
// Default fallback
return 'Error';
})()}
</span>
</button>
{/* Error popover */}
{showErrorPopover && (machineErrorMessage || pyodideError) && (
<ErrorPopover
ref={errorPopoverRef}
machineError={machineError}
isPairingError={isPairingError}
errorMessage={machineErrorMessage}
pyodideError={pyodideError}
/>
)}
</div>
</div>
</div>
</div>
{/* Workflow Stepper - Flexible width column */}
<div>
<WorkflowStepper />
</div>
</div>
</header>
);
}

View file

@ -1,67 +0,0 @@
import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from '../stores/useMachineStore';
import { isBluetoothSupported } from '../utils/bluetoothSupport';
import { ExclamationTriangleIcon } from '@heroicons/react/24/solid';
export function ConnectionPrompt() {
const { connect } = useMachineStore(
useShallow((state) => ({
connect: state.connect,
}))
);
if (isBluetoothSupported()) {
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p>
</div>
</div>
<button
onClick={connect}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
>
Connect to Machine
</button>
</div>
);
}
return (
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg shadow-md border-l-4 border-amber-500 dark:border-amber-600">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-amber-900 dark:text-amber-100 mb-2">Browser Not Supported</h3>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-3">
Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
</p>
<div className="space-y-2">
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">Please try one of these options:</p>
<ul className="text-sm text-amber-800 dark:text-amber-200 space-y-1.5 ml-4 list-disc">
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
<li>
Download the Desktop app from{' '}
<a
href="https://github.com/jhbruhn/respira/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="font-semibold underline hover:text-amber-900 dark:hover:text-amber-50 transition-colors"
>
GitHub Releases
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,84 +0,0 @@
import { forwardRef } from 'react';
import { ExclamationTriangleIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
import { getErrorDetails } from '../utils/errorCodeHelpers';
interface ErrorPopoverProps {
machineError?: number;
isPairingError: boolean;
errorMessage?: string | null;
pyodideError?: string | null;
}
export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
const errorDetails = getErrorDetails(machineError);
const isPairingErr = isPairingError;
const errorMsg = pyodideError || errorMessage || '';
const isInfo = isPairingErr || errorDetails?.isInformational;
const bgColor = isInfo
? 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500'
: 'bg-red-50 dark:bg-red-900/95 border-red-600 dark:border-red-500';
const iconColor = isInfo
? 'text-blue-600 dark:text-blue-400'
: 'text-red-600 dark:text-red-400';
const textColor = isInfo
? 'text-blue-900 dark:text-blue-200'
: 'text-red-900 dark:text-red-200';
const descColor = isInfo
? 'text-blue-800 dark:text-blue-300'
: 'text-red-800 dark:text-red-300';
const listColor = isInfo
? 'text-blue-700 dark:text-blue-300'
: 'text-red-700 dark:text-red-300';
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error');
return (
<div
ref={ref}
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
role="dialog"
aria-label="Error details"
>
<div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
<div className="flex items-start gap-3">
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1">
<h3 className={`text-base font-semibold ${textColor} mb-2`}>
{title}
</h3>
<p className={`text-sm ${descColor} mb-3`}>
{errorDetails?.description || errorMsg}
</p>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<>
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
{isInfo ? 'Steps:' : 'How to Fix:'}
</h4>
<ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}>
{errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2">{solution}</li>
))}
</ol>
</>
)}
{machineError !== undefined && !errorDetails?.isInformational && (
<p className={`text-xs ${descColor} mt-3 font-mono`}>
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
</p>
)}
</div>
</div>
</div>
</div>
);
}
);
ErrorPopover.displayName = 'ErrorPopover';

View file

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from '../stores/usePatternStore';
import { useUIStore } from '../stores/useUIStore'; import { useUIStore } from '../stores/useUIStore';
import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter'; import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter';
@ -40,19 +40,18 @@ export function FileUpload() {
pesData: pesDataProp, pesData: pesDataProp,
currentFileName, currentFileName,
patternOffset, patternOffset,
patternUploaded,
setPattern, setPattern,
} = usePatternStore( } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
currentFileName: state.currentFileName, currentFileName: state.currentFileName,
patternOffset: state.patternOffset, patternOffset: state.patternOffset,
patternUploaded: state.patternUploaded,
setPattern: state.setPattern, setPattern: state.setPattern,
})) }))
); );
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
// UI store // UI store
const { const {
pyodideReady, pyodideReady,

View file

@ -1,44 +0,0 @@
import { useShallow } from 'zustand/react/shallow';
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
import { usePatternStore } from '../stores/usePatternStore';
import { ConnectionPrompt } from './ConnectionPrompt';
import { FileUpload } from './FileUpload';
import { PatternSummaryCard } from './PatternSummaryCard';
import { ProgressMonitor } from './ProgressMonitor';
export function LeftSidebar() {
const { isConnected } = useMachineStore(
useShallow((state) => ({
isConnected: state.isConnected,
}))
);
const { pesData } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
}))
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
return (
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
{/* Connect Button or Browser Hint - Show when disconnected */}
{!isConnected && <ConnectionPrompt />}
{/* Pattern File - Show during upload stage (before pattern is uploaded) */}
{isConnected && !patternUploaded && <FileUpload />}
{/* Compact Pattern Summary - Show after upload (during sewing stages) */}
{isConnected && patternUploaded && pesData && <PatternSummaryCard />}
{/* Progress Monitor - Show when pattern is uploaded */}
{isConnected && patternUploaded && (
<div className="lg:flex-1 lg:min-h-0">
<ProgressMonitor />
</div>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from '../stores/usePatternStore';
import { Stage, Layer, Group } from 'react-konva'; import { Stage, Layer, Group } from 'react-konva';
import Konva from 'konva'; import Konva from 'konva';
@ -27,17 +27,16 @@ export function PatternCanvas() {
const { const {
pesData, pesData,
patternOffset: initialPatternOffset, patternOffset: initialPatternOffset,
patternUploaded,
setPatternOffset, setPatternOffset,
} = usePatternStore( } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
patternOffset: state.patternOffset, patternOffset: state.patternOffset,
patternUploaded: state.patternUploaded,
setPatternOffset: state.setPatternOffset, setPatternOffset: state.setPatternOffset,
})) }))
); );
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null); const stageRef = useRef<Konva.Stage | null>(null);

View file

@ -1,46 +0,0 @@
export function PatternPreviewPlaceholder() {
return (
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2>
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
{/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10">
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
</div>
<div className="text-center relative z-10">
<div className="relative inline-block mb-6">
<svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
</div>
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
Connect to your machine and choose a PES embroidery file to see your design preview
</p>
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></div>
<span>Drag to Position</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-400 dark:bg-green-500 rounded-full"></div>
<span>Zoom & Pan</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-purple-400 dark:bg-purple-500 rounded-full"></div>
<span>Real-time Preview</span>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -21,7 +21,6 @@ import {
canResumeSewing, canResumeSewing,
getStateVisualInfo, getStateVisualInfo,
} from "../utils/machineStateHelpers"; } from "../utils/machineStateHelpers";
import { calculatePatternTime } from "../utils/timeCalculation";
export function ProgressMonitor() { export function ProgressMonitor() {
// Machine store // Machine store
@ -57,15 +56,8 @@ export function ProgressMonitor() {
const stateVisual = getStateVisualInfo(machineStatus); const stateVisual = getStateVisualInfo(machineStatus);
// Use PEN stitch count as fallback when machine reports 0 total stitches const progressPercent = patternInfo
const totalStitches = patternInfo ? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
? (patternInfo.totalStitches === 0 && pesData?.penStitches
? pesData.penStitches.stitches.length
: patternInfo.totalStitches)
: 0;
const progressPercent = totalStitches > 0
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
: 0; : 0;
// Calculate color block information from decoded penStitches // Calculate color block information from decoded penStitches
@ -110,15 +102,6 @@ export function ProgressMonitor() {
currentStitch >= block.startStitch && currentStitch < block.endStitch, currentStitch >= block.startStitch && currentStitch < block.endStitch,
); );
// Calculate time based on color blocks (matches Brother app calculation)
const { totalMinutes, elapsedMinutes } = useMemo(() => {
if (colorBlocks.length === 0) {
return { totalMinutes: 0, elapsedMinutes: 0 };
}
const result = calculatePatternTime(colorBlocks, currentStitch);
return { totalMinutes: result.totalMinutes, elapsedMinutes: result.elapsedMinutes };
}, [colorBlocks, currentStitch]);
// Auto-scroll to current block // Auto-scroll to current block
useEffect(() => { useEffect(() => {
if (currentBlockRef.current) { if (currentBlockRef.current) {
@ -190,15 +173,16 @@ export function ProgressMonitor() {
Total Stitches Total Stitches
</span> </span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{totalStitches.toLocaleString()} {patternInfo.totalStitches.toLocaleString()}
</span> </span>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">
Total Time Est. Time
</span> </span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{totalMinutes} min {Math.floor(patternInfo.totalTime / 60)}:
{String(patternInfo.totalTime % 60).padStart(2, "0")}
</span> </span>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
@ -229,15 +213,16 @@ export function ProgressMonitor() {
</span> </span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{sewingProgress.currentStitch.toLocaleString()} /{" "} {sewingProgress.currentStitch.toLocaleString()} /{" "}
{totalStitches.toLocaleString()} {patternInfo?.totalStitches.toLocaleString() || 0}
</span> </span>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">
Time Time Elapsed
</span> </span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{elapsedMinutes} / {totalMinutes} min {Math.floor(sewingProgress.currentTime / 60)}:
{String(sewingProgress.currentTime % 60).padStart(2, "0")}
</span> </span>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from '../stores/usePatternStore';
import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid'; import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid';
import { MachineStatus } from '../types/machine'; import { MachineStatus } from '../types/machine';
@ -268,14 +268,14 @@ export function WorkflowStepper() {
// Pattern store // Pattern store
const { const {
pesData, pesData,
patternUploaded,
} = usePatternStore( } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
patternUploaded: state.patternUploaded,
})) }))
); );
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null; const hasPattern = pesData !== null;
const hasErrorFlag = hasError(machineError); const hasErrorFlag = hasError(machineError);
const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded); const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded);

View file

@ -0,0 +1,457 @@
import { useState, useCallback, useEffect } from "react";
import { BrotherPP1Service, BluetoothPairingError } from "../services/BrotherPP1Service";
import type {
MachineInfo,
PatternInfo,
SewingProgress,
} from "../types/machine";
import { MachineStatus, MachineStatusNames } from "../types/machine";
import {
uuidToString,
} from "../services/PatternCacheService";
import type { IStorageService } from "../platform/interfaces/IStorageService";
import { createStorageService } from "../platform";
import type { PesPatternData } from "../formats/import/pesImporter";
import { SewingMachineError } from "../utils/errorCodeHelpers";
export function useBrotherMachine() {
const [service] = useState(() => new BrotherPP1Service());
const [storageService] = useState<IStorageService>(() => createStorageService());
const [isConnected, setIsConnected] = useState(false);
const [machineInfo, setMachineInfo] = useState<MachineInfo | null>(null);
const [machineStatus, setMachineStatus] = useState<MachineStatus>(
MachineStatus.None,
);
const [machineError, setMachineError] = useState<number>(SewingMachineError.None);
const [patternInfo, setPatternInfo] = useState<PatternInfo | null>(null);
const [sewingProgress, setSewingProgress] = useState<SewingProgress | null>(
null,
);
const [uploadProgress, setUploadProgress] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
const [isPairingError, setIsPairingError] = useState(false);
const [isCommunicating, setIsCommunicating] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [resumeAvailable, setResumeAvailable] = useState(false);
const [resumeFileName, setResumeFileName] = useState<string | null>(null);
const [resumedPattern, setResumedPattern] = useState<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null>(
null,
);
// Subscribe to service communication state
useEffect(() => {
const unsubscribe = service.onCommunicationChange(setIsCommunicating);
return unsubscribe;
}, [service]);
// Subscribe to disconnect events
useEffect(() => {
const unsubscribe = service.onDisconnect(() => {
console.log('[useBrotherMachine] Device disconnected');
setIsConnected(false);
setMachineInfo(null);
setMachineStatus(MachineStatus.None);
setMachineError(SewingMachineError.None);
setPatternInfo(null);
setSewingProgress(null);
setError('Device disconnected');
setIsPairingError(false);
});
return unsubscribe;
}, [service]);
// Define checkResume first (before connect uses it)
const checkResume = useCallback(async (): Promise<PesPatternData | null> => {
try {
console.log("[Resume] Checking for cached pattern...");
// Get UUID from machine
const machineUuid = await service.getPatternUUID();
console.log(
"[Resume] Machine UUID:",
machineUuid ? uuidToString(machineUuid) : "none",
);
if (!machineUuid) {
console.log("[Resume] No pattern loaded on machine");
setResumeAvailable(false);
setResumeFileName(null);
return null;
}
// Check if we have this pattern cached
const uuidStr = uuidToString(machineUuid);
const cached = await storageService.getPatternByUUID(uuidStr);
if (cached) {
console.log("[Resume] Pattern found in cache:", cached.fileName, "Offset:", cached.patternOffset);
console.log("[Resume] Auto-loading cached pattern...");
setResumeAvailable(true);
setResumeFileName(cached.fileName);
setResumedPattern({ pesData: cached.pesData, patternOffset: cached.patternOffset });
// Fetch pattern info from machine
try {
const info = await service.getPatternInfo();
setPatternInfo(info);
console.log("[Resume] Pattern info loaded from machine");
} catch (err) {
console.error("[Resume] Failed to load pattern info:", err);
}
// Return the cached pattern data to be loaded
return cached.pesData;
} else {
console.log("[Resume] Pattern on machine not found in cache");
setResumeAvailable(false);
setResumeFileName(null);
return null;
}
} catch (err) {
console.error("[Resume] Failed to check resume:", err);
setResumeAvailable(false);
setResumeFileName(null);
return null;
}
}, [service, storageService]);
const connect = useCallback(async () => {
try {
setError(null);
setIsPairingError(false);
await service.connect();
setIsConnected(true);
// Fetch initial machine info and status
const info = await service.getMachineInfo();
setMachineInfo(info);
const state = await service.getMachineState();
setMachineStatus(state.status);
setMachineError(state.error);
// Check for resume possibility
await checkResume();
} catch (err) {
console.log(err);
const isPairing = err instanceof BluetoothPairingError;
setIsPairingError(isPairing);
setError(err instanceof Error ? err.message : "Failed to connect");
setIsConnected(false);
}
}, [service, checkResume]);
const disconnect = useCallback(async () => {
try {
await service.disconnect();
setIsConnected(false);
setMachineInfo(null);
setMachineStatus(MachineStatus.None);
setPatternInfo(null);
setSewingProgress(null);
setError(null);
setMachineError(SewingMachineError.None);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to disconnect");
}
}, [service]);
const refreshStatus = useCallback(async () => {
if (!isConnected) return;
try {
const state = await service.getMachineState();
setMachineStatus(state.status);
setMachineError(state.error);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to get status");
}
}, [service, isConnected]);
const refreshPatternInfo = useCallback(async () => {
if (!isConnected) return;
try {
const info = await service.getPatternInfo();
setPatternInfo(info);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to get pattern info",
);
}
}, [service, isConnected]);
const refreshProgress = useCallback(async () => {
if (!isConnected) return;
try {
const progress = await service.getSewingProgress();
setSewingProgress(progress);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to get progress");
}
}, [service, isConnected]);
const refreshServiceCount = useCallback(async () => {
if (!isConnected || !machineInfo) return;
try {
const counts = await service.getServiceCount();
setMachineInfo({
...machineInfo,
serviceCount: counts.serviceCount,
totalCount: counts.totalCount,
});
} catch (err) {
// Don't set error for service count failures - it's not critical
console.warn("Failed to get service count:", err);
}
}, [service, isConnected, machineInfo]);
const loadCachedPattern =
useCallback(async (): Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null> => {
if (!resumeAvailable) return null;
try {
const machineUuid = await service.getPatternUUID();
if (!machineUuid) return null;
const uuidStr = uuidToString(machineUuid);
const cached = await storageService.getPatternByUUID(uuidStr);
if (cached) {
console.log("[Resume] Loading cached pattern:", cached.fileName, "Offset:", cached.patternOffset);
// Refresh pattern info from machine
await refreshPatternInfo();
return { pesData: cached.pesData, patternOffset: cached.patternOffset };
}
return null;
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load cached pattern",
);
return null;
}
}, [service, storageService, resumeAvailable, refreshPatternInfo]);
const uploadPattern = useCallback(
async (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => {
if (!isConnected) {
setError("Not connected to machine");
return;
}
try {
setError(null);
setUploadProgress(0);
setIsUploading(true); // Set loading state immediately
const uuid = await service.uploadPattern(
penData,
(progress) => {
setUploadProgress(progress);
},
pesData.bounds,
patternOffset,
);
setUploadProgress(100);
// Cache the pattern with its UUID and offset
const uuidStr = uuidToString(uuid);
storageService.savePattern(uuidStr, pesData, fileName, patternOffset);
console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr, "Offset:", patternOffset);
// Clear resume state since we just uploaded
setResumeAvailable(false);
setResumeFileName(null);
// Refresh status after upload
// NOTE: We don't call refreshPatternInfo() here because the machine hasn't
// finished processing the pattern yet. Pattern info (stitch count, time estimate)
// is only available AFTER startMaskTrace() is called.
await refreshStatus();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to upload pattern",
);
} finally {
setIsUploading(false); // Clear loading state
}
},
[service, storageService, isConnected, refreshStatus],
);
const startMaskTrace = useCallback(async () => {
if (!isConnected) return;
try {
setError(null);
await service.startMaskTrace();
// After mask trace, poll machine status a few times to ensure it's ready
// The machine needs time to process the pattern before pattern info is accurate
console.log('[MaskTrace] Polling machine status...');
for (let i = 0; i < 3; i++) {
await new Promise(resolve => setTimeout(resolve, 200));
await refreshStatus();
}
// Now the machine should have accurate pattern info
console.log('[MaskTrace] Refreshing pattern info...');
await refreshPatternInfo();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to start mask trace",
);
}
}, [service, isConnected, refreshStatus, refreshPatternInfo]);
const startSewing = useCallback(async () => {
if (!isConnected) return;
try {
setError(null);
await service.startSewing();
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start sewing");
}
}, [service, isConnected, refreshStatus]);
const resumeSewing = useCallback(async () => {
if (!isConnected) return;
try {
setError(null);
await service.resumeSewing();
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resume sewing");
}
}, [service, isConnected, refreshStatus]);
const deletePattern = useCallback(async () => {
if (!isConnected) return;
try {
setError(null);
setIsDeleting(true); // Set loading state immediately
// Delete pattern from cache to prevent auto-resume
try {
const machineUuid = await service.getPatternUUID();
if (machineUuid) {
const uuidStr = uuidToString(machineUuid);
await storageService.deletePattern(uuidStr);
console.log("[Cache] Deleted pattern with UUID:", uuidStr);
}
} catch (err) {
console.warn("[Cache] Failed to get UUID for cache deletion:", err);
}
await service.deletePattern();
// Clear machine-related state but keep pattern data in UI for re-editing
setPatternInfo(null);
setSewingProgress(null);
setUploadProgress(0); // Reset upload progress to allow new uploads
setResumeAvailable(false);
setResumeFileName(null);
// NOTE: We intentionally DON'T clear setResumedPattern(null)
// so the pattern remains visible in the canvas for re-editing
// However, we DO need to preserve pesData in App.tsx for re-upload
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete pattern");
} finally {
setIsDeleting(false); // Clear loading state
}
}, [service, storageService, isConnected, refreshStatus]);
// Periodic status monitoring when connected
useEffect(() => {
if (!isConnected) {
return;
}
// Determine polling interval based on machine status
let pollInterval = 2000; // Default: 2 seconds for idle states
// Fast polling for active states
if (
machineStatus === MachineStatus.SEWING ||
machineStatus === MachineStatus.MASK_TRACING ||
machineStatus === MachineStatus.SEWING_DATA_RECEIVE
) {
pollInterval = 500; // 500ms for active operations
} else if (
machineStatus === MachineStatus.COLOR_CHANGE_WAIT ||
machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT ||
machineStatus === MachineStatus.SEWING_WAIT
) {
pollInterval = 1000; // 1 second for waiting states
}
const interval = setInterval(async () => {
await refreshStatus();
// Refresh progress during sewing
if (machineStatus === MachineStatus.SEWING) {
await refreshProgress();
}
}, pollInterval);
// Separate interval for service count (slower update rate - every 10 seconds)
const serviceCountInterval = setInterval(async () => {
await refreshServiceCount();
}, 10000);
return () => {
clearInterval(interval);
clearInterval(serviceCountInterval);
};
}, [isConnected, machineStatus, refreshStatus, refreshProgress, refreshServiceCount]);
// Refresh pattern info when status changes to SEWING_WAIT
// (indicates pattern was just uploaded or is ready)
useEffect(() => {
if (!isConnected) return;
if (machineStatus === MachineStatus.SEWING_WAIT && !patternInfo) {
refreshPatternInfo();
}
}, [isConnected, machineStatus, patternInfo, refreshPatternInfo]);
return {
isConnected,
machineInfo,
machineStatus,
machineStatusName: MachineStatusNames[machineStatus] || "Unknown",
machineError,
patternInfo,
sewingProgress,
uploadProgress,
error,
isPairingError,
isPolling: isCommunicating,
isUploading,
isDeleting,
resumeAvailable,
resumeFileName,
resumedPattern,
connect,
disconnect,
refreshStatus,
refreshPatternInfo,
uploadPattern,
startMaskTrace,
startSewing,
resumeSewing,
deletePattern,
checkResume,
loadCachedPattern,
};
}

View file

@ -1,19 +1,16 @@
import { create } from "zustand"; import { create } from 'zustand';
import { import { BrotherPP1Service, BluetoothPairingError } from '../services/BrotherPP1Service';
BrotherPP1Service,
BluetoothPairingError,
} from "../services/BrotherPP1Service";
import type { import type {
MachineInfo, MachineInfo,
PatternInfo, PatternInfo,
SewingProgress, SewingProgress,
} from "../types/machine"; } from '../types/machine';
import { MachineStatus, MachineStatusNames } from "../types/machine"; import { MachineStatus, MachineStatusNames } from '../types/machine';
import { SewingMachineError } from "../utils/errorCodeHelpers"; import { SewingMachineError } from '../utils/errorCodeHelpers';
import { uuidToString } from "../services/PatternCacheService"; import { uuidToString } from '../services/PatternCacheService';
import { createStorageService } from "../platform"; import { createStorageService } from '../platform';
import type { IStorageService } from "../platform/interfaces/IStorageService"; import type { IStorageService } from '../platform/interfaces/IStorageService';
import type { PesPatternData } from "../formats/import/pesImporter"; import type { PesPatternData } from '../formats/import/pesImporter';
interface MachineState { interface MachineState {
// Service instances // Service instances
@ -40,10 +37,7 @@ interface MachineState {
// Resume state // Resume state
resumeAvailable: boolean; resumeAvailable: boolean;
resumeFileName: string | null; resumeFileName: string | null;
resumedPattern: { resumedPattern: { pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null;
pesData: PesPatternData;
patternOffset?: { x: number; y: number };
} | null;
// Error state // Error state
error: string | null; error: string | null;
@ -68,17 +62,14 @@ interface MachineState {
penData: Uint8Array, penData: Uint8Array,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number }, patternOffset?: { x: number; y: number }
) => Promise<void>; ) => Promise<void>;
startMaskTrace: () => Promise<void>; startMaskTrace: () => Promise<void>;
startSewing: () => Promise<void>; startSewing: () => Promise<void>;
resumeSewing: () => Promise<void>; resumeSewing: () => Promise<void>;
deletePattern: () => Promise<void>; deletePattern: () => Promise<void>;
checkResume: () => Promise<PesPatternData | null>; checkResume: () => Promise<PesPatternData | null>;
loadCachedPattern: () => Promise<{ loadCachedPattern: () => Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null>;
pesData: PesPatternData;
patternOffset?: { x: number; y: number };
} | null>;
// Internal methods // Internal methods
_setupSubscriptions: () => void; _setupSubscriptions: () => void;
@ -93,7 +84,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
isConnected: false, isConnected: false,
machineInfo: null, machineInfo: null,
machineStatus: MachineStatus.None, machineStatus: MachineStatus.None,
machineStatusName: MachineStatusNames[MachineStatus.None] || "Unknown", machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
machineError: SewingMachineError.None, machineError: SewingMachineError.None,
patternInfo: null, patternInfo: null,
sewingProgress: null, sewingProgress: null,
@ -113,16 +104,16 @@ export const useMachineStore = create<MachineState>((set, get) => ({
checkResume: async (): Promise<PesPatternData | null> => { checkResume: async (): Promise<PesPatternData | null> => {
try { try {
const { service, storageService } = get(); const { service, storageService } = get();
console.log("[Resume] Checking for cached pattern..."); console.log('[Resume] Checking for cached pattern...');
const machineUuid = await service.getPatternUUID(); const machineUuid = await service.getPatternUUID();
console.log( console.log(
"[Resume] Machine UUID:", '[Resume] Machine UUID:',
machineUuid ? uuidToString(machineUuid) : "none", machineUuid ? uuidToString(machineUuid) : 'none',
); );
if (!machineUuid) { if (!machineUuid) {
console.log("[Resume] No pattern loaded on machine"); console.log('[Resume] No pattern loaded on machine');
set({ resumeAvailable: false, resumeFileName: null }); set({ resumeAvailable: false, resumeFileName: null });
return null; return null;
} }
@ -131,39 +122,31 @@ export const useMachineStore = create<MachineState>((set, get) => ({
const cached = await storageService.getPatternByUUID(uuidStr); const cached = await storageService.getPatternByUUID(uuidStr);
if (cached) { if (cached) {
console.log( console.log('[Resume] Pattern found in cache:', cached.fileName, 'Offset:', cached.patternOffset);
"[Resume] Pattern found in cache:", console.log('[Resume] Auto-loading cached pattern...');
cached.fileName,
"Offset:",
cached.patternOffset,
);
console.log("[Resume] Auto-loading cached pattern...");
set({ set({
resumeAvailable: true, resumeAvailable: true,
resumeFileName: cached.fileName, resumeFileName: cached.fileName,
resumedPattern: { resumedPattern: { pesData: cached.pesData, patternOffset: cached.patternOffset },
pesData: cached.pesData,
patternOffset: cached.patternOffset,
},
}); });
// Fetch pattern info from machine // Fetch pattern info from machine
try { try {
const info = await service.getPatternInfo(); const info = await service.getPatternInfo();
set({ patternInfo: info }); set({ patternInfo: info });
console.log("[Resume] Pattern info loaded from machine"); console.log('[Resume] Pattern info loaded from machine');
} catch (err) { } catch (err) {
console.error("[Resume] Failed to load pattern info:", err); console.error('[Resume] Failed to load pattern info:', err);
} }
return cached.pesData; return cached.pesData;
} else { } else {
console.log("[Resume] Pattern on machine not found in cache"); console.log('[Resume] Pattern on machine not found in cache');
set({ resumeAvailable: false, resumeFileName: null }); set({ resumeAvailable: false, resumeFileName: null });
return null; return null;
} }
} catch (err) { } catch (err) {
console.error("[Resume] Failed to check resume:", err); console.error('[Resume] Failed to check resume:', err);
set({ resumeAvailable: false, resumeFileName: null }); set({ resumeAvailable: false, resumeFileName: null });
return null; return null;
} }
@ -185,7 +168,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
set({ set({
machineInfo: info, machineInfo: info,
machineStatus: state.status, machineStatus: state.status,
machineStatusName: MachineStatusNames[state.status] || "Unknown", machineStatusName: MachineStatusNames[state.status] || 'Unknown',
machineError: state.error, machineError: state.error,
}); });
@ -199,7 +182,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
const isPairing = err instanceof BluetoothPairingError; const isPairing = err instanceof BluetoothPairingError;
set({ set({
isPairingError: isPairing, isPairingError: isPairing,
error: err instanceof Error ? err.message : "Failed to connect", error: err instanceof Error ? err.message : 'Failed to connect',
isConnected: false, isConnected: false,
}); });
} }
@ -216,7 +199,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
isConnected: false, isConnected: false,
machineInfo: null, machineInfo: null,
machineStatus: MachineStatus.None, machineStatus: MachineStatus.None,
machineStatusName: MachineStatusNames[MachineStatus.None] || "Unknown", machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
patternInfo: null, patternInfo: null,
sewingProgress: null, sewingProgress: null,
error: null, error: null,
@ -224,7 +207,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
}); });
} catch (err) { } catch (err) {
set({ set({
error: err instanceof Error ? err.message : "Failed to disconnect", error: err instanceof Error ? err.message : 'Failed to disconnect',
}); });
} }
}, },
@ -238,12 +221,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
const state = await service.getMachineState(); const state = await service.getMachineState();
set({ set({
machineStatus: state.status, machineStatus: state.status,
machineStatusName: MachineStatusNames[state.status] || "Unknown", machineStatusName: MachineStatusNames[state.status] || 'Unknown',
machineError: state.error, machineError: state.error,
}); });
} catch (err) { } catch (err) {
set({ set({
error: err instanceof Error ? err.message : "Failed to get status", error: err instanceof Error ? err.message : 'Failed to get status',
}); });
} }
}, },
@ -258,8 +241,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
set({ patternInfo: info }); set({ patternInfo: info });
} catch (err) { } catch (err) {
set({ set({
error: error: err instanceof Error ? err.message : 'Failed to get pattern info',
err instanceof Error ? err.message : "Failed to get pattern info",
}); });
} }
}, },
@ -274,7 +256,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
set({ sewingProgress: progress }); set({ sewingProgress: progress });
} catch (err) { } catch (err) {
set({ set({
error: err instanceof Error ? err.message : "Failed to get progress", error: err instanceof Error ? err.message : 'Failed to get progress',
}); });
} }
}, },
@ -294,7 +276,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
}, },
}); });
} catch (err) { } catch (err) {
console.warn("Failed to get service count:", err); console.warn('Failed to get service count:', err);
} }
}, },
@ -303,17 +285,11 @@ export const useMachineStore = create<MachineState>((set, get) => ({
penData: Uint8Array, penData: Uint8Array,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number }, patternOffset?: { x: number; y: number }
) => { ) => {
const { const { isConnected, service, storageService, refreshStatus, refreshPatternInfo } = get();
isConnected,
service,
storageService,
refreshStatus,
refreshPatternInfo,
} = get();
if (!isConnected) { if (!isConnected) {
set({ error: "Not connected to machine" }); set({ error: 'Not connected to machine' });
return; return;
} }
@ -334,14 +310,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
// Cache the pattern with its UUID and offset // Cache the pattern with its UUID and offset
const uuidStr = uuidToString(uuid); const uuidStr = uuidToString(uuid);
storageService.savePattern(uuidStr, pesData, fileName, patternOffset); storageService.savePattern(uuidStr, pesData, fileName, patternOffset);
console.log( console.log('[Cache] Saved pattern:', fileName, 'with UUID:', uuidStr, 'Offset:', patternOffset);
"[Cache] Saved pattern:",
fileName,
"with UUID:",
uuidStr,
"Offset:",
patternOffset,
);
// Clear resume state since we just uploaded // Clear resume state since we just uploaded
set({ set({
@ -354,7 +323,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
await refreshPatternInfo(); await refreshPatternInfo();
} catch (err) { } catch (err) {
set({ set({
error: err instanceof Error ? err.message : "Failed to upload pattern", error: err instanceof Error ? err.message : 'Failed to upload pattern',
}); });
} finally { } finally {
set({ isUploading: false }); set({ isUploading: false });
@ -372,8 +341,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
await refreshStatus(); await refreshStatus();
} catch (err) { } catch (err) {
set({ set({
error: error: err instanceof Error ? err.message : 'Failed to start mask trace',
err instanceof Error ? err.message : "Failed to start mask trace",
}); });
} }
}, },
@ -389,7 +357,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
await refreshStatus(); await refreshStatus();
} catch (err) { } catch (err) {
set({ set({
error: err instanceof Error ? err.message : "Failed to start sewing", error: err instanceof Error ? err.message : 'Failed to start sewing',
}); });
} }
}, },
@ -405,7 +373,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
await refreshStatus(); await refreshStatus();
} catch (err) { } catch (err) {
set({ set({
error: err instanceof Error ? err.message : "Failed to resume sewing", error: err instanceof Error ? err.message : 'Failed to resume sewing',
}); });
} }
}, },
@ -424,10 +392,10 @@ export const useMachineStore = create<MachineState>((set, get) => ({
if (machineUuid) { if (machineUuid) {
const uuidStr = uuidToString(machineUuid); const uuidStr = uuidToString(machineUuid);
await storageService.deletePattern(uuidStr); await storageService.deletePattern(uuidStr);
console.log("[Cache] Deleted pattern with UUID:", uuidStr); console.log('[Cache] Deleted pattern with UUID:', uuidStr);
} }
} catch (err) { } catch (err) {
console.warn("[Cache] Failed to get UUID for cache deletion:", err); console.warn('[Cache] Failed to get UUID for cache deletion:', err);
} }
await service.deletePattern(); await service.deletePattern();
@ -444,7 +412,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
await refreshStatus(); await refreshStatus();
} catch (err) { } catch (err) {
set({ set({
error: err instanceof Error ? err.message : "Failed to delete pattern", error: err instanceof Error ? err.message : 'Failed to delete pattern',
}); });
} finally { } finally {
set({ isDeleting: false }); set({ isDeleting: false });
@ -452,12 +420,8 @@ export const useMachineStore = create<MachineState>((set, get) => ({
}, },
// Load cached pattern // Load cached pattern
loadCachedPattern: async (): Promise<{ loadCachedPattern: async (): Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null> => {
pesData: PesPatternData; const { resumeAvailable, service, storageService, refreshPatternInfo } = get();
patternOffset?: { x: number; y: number };
} | null> => {
const { resumeAvailable, service, storageService, refreshPatternInfo } =
get();
if (!resumeAvailable) return null; if (!resumeAvailable) return null;
try { try {
@ -468,12 +432,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
const cached = await storageService.getPatternByUUID(uuidStr); const cached = await storageService.getPatternByUUID(uuidStr);
if (cached) { if (cached) {
console.log( console.log('[Resume] Loading cached pattern:', cached.fileName, 'Offset:', cached.patternOffset);
"[Resume] Loading cached pattern:",
cached.fileName,
"Offset:",
cached.patternOffset,
);
await refreshPatternInfo(); await refreshPatternInfo();
return { pesData: cached.pesData, patternOffset: cached.patternOffset }; return { pesData: cached.pesData, patternOffset: cached.patternOffset };
} }
@ -481,8 +440,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
return null; return null;
} catch (err) { } catch (err) {
set({ set({
error: error: err instanceof Error ? err.message : 'Failed to load cached pattern',
err instanceof Error ? err.message : "Failed to load cached pattern",
}); });
return null; return null;
} }
@ -499,17 +457,17 @@ export const useMachineStore = create<MachineState>((set, get) => ({
// Subscribe to disconnect events // Subscribe to disconnect events
service.onDisconnect(() => { service.onDisconnect(() => {
console.log("[useMachineStore] Device disconnected"); console.log('[useMachineStore] Device disconnected');
get()._stopPolling(); get()._stopPolling();
set({ set({
isConnected: false, isConnected: false,
machineInfo: null, machineInfo: null,
machineStatus: MachineStatus.None, machineStatus: MachineStatus.None,
machineStatusName: MachineStatusNames[MachineStatus.None] || "Unknown", machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
machineError: SewingMachineError.None, machineError: SewingMachineError.None,
patternInfo: null, patternInfo: null,
sewingProgress: null, sewingProgress: null,
error: "Device disconnected", error: 'Device disconnected',
isPairingError: false, isPairingError: false,
}); });
}); });
@ -517,13 +475,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
// Start polling for status updates // Start polling for status updates
_startPolling: () => { _startPolling: () => {
const { const { _stopPolling, refreshStatus, refreshProgress, refreshServiceCount } = get();
_stopPolling,
refreshStatus,
refreshProgress,
refreshServiceCount,
refreshPatternInfo,
} = get();
// Stop any existing polling // Stop any existing polling
_stopPolling(); _stopPolling();
@ -558,11 +510,6 @@ export const useMachineStore = create<MachineState>((set, get) => ({
await refreshProgress(); await refreshProgress();
} }
// follows the apps logic:
if (get().resumeAvailable && get().patternInfo?.totalStitches == 0) {
await refreshPatternInfo();
}
// Schedule next poll with updated interval // Schedule next poll with updated interval
const newInterval = getPollInterval(); const newInterval = getPollInterval();
const pollIntervalId = setTimeout(poll, newInterval); const pollIntervalId = setTimeout(poll, newInterval);
@ -599,18 +546,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
useMachineStore.getState()._setupSubscriptions(); useMachineStore.getState()._setupSubscriptions();
// Selector hooks for common use cases // Selector hooks for common use cases
export const useIsConnected = () => export const useIsConnected = () => useMachineStore((state) => state.isConnected);
useMachineStore((state) => state.isConnected); export const useMachineInfo = () => useMachineStore((state) => state.machineInfo);
export const useMachineInfo = () => export const useMachineStatus = () => useMachineStore((state) => state.machineStatus);
useMachineStore((state) => state.machineInfo); export const useMachineError = () => useMachineStore((state) => state.machineError);
export const useMachineStatus = () => export const usePatternInfo = () => useMachineStore((state) => state.patternInfo);
useMachineStore((state) => state.machineStatus); export const useSewingProgress = () => useMachineStore((state) => state.sewingProgress);
export const useMachineError = () =>
useMachineStore((state) => state.machineError);
export const usePatternInfo = () =>
useMachineStore((state) => state.patternInfo);
export const useSewingProgress = () =>
useMachineStore((state) => state.sewingProgress);
// Derived state: pattern is uploaded if machine has pattern info
export const usePatternUploaded = () =>
useMachineStore((state) => state.patternInfo !== null);

View file

@ -1,67 +0,0 @@
/**
* Convert stitch count to minutes using Brother PP1 timing formula
* Formula: ((pointCount - 1) * 150 + 3000) / 60000
* - 150ms per stitch
* - 3000ms startup time
* - Result in minutes (rounded up)
*/
export function convertStitchesToMinutes(stitchCount: number): number {
if (stitchCount <= 1) return 0;
const timeMs = (stitchCount - 1) * 150 + 3000;
const timeMin = Math.ceil(timeMs / 60000);
return timeMin < 1 ? 1 : timeMin;
}
/**
* Calculate total and elapsed time for a pattern based on color blocks
* This matches the Brother app's calculation method
*/
export function calculatePatternTime(
colorBlocks: Array<{ stitchCount: number }>,
currentStitch: number
): {
totalMinutes: number;
elapsedMinutes: number;
remainingMinutes: number;
} {
let totalMinutes = 0;
let elapsedMinutes = 0;
let cumulativeStitches = 0;
// Calculate time per color block
for (const block of colorBlocks) {
totalMinutes += convertStitchesToMinutes(block.stitchCount);
cumulativeStitches += block.stitchCount;
if (cumulativeStitches < currentStitch) {
// This entire block is completed
elapsedMinutes += convertStitchesToMinutes(block.stitchCount);
} else if (cumulativeStitches === currentStitch) {
// We just completed this block
elapsedMinutes += convertStitchesToMinutes(block.stitchCount);
break;
} else {
// We're partway through this block
const stitchesInBlock = currentStitch - (cumulativeStitches - block.stitchCount);
elapsedMinutes += convertStitchesToMinutes(stitchesInBlock);
break;
}
}
return {
totalMinutes,
elapsedMinutes,
remainingMinutes: Math.max(0, totalMinutes - elapsedMinutes),
};
}
/**
* Format minutes as MM:SS
*/
export function formatMinutes(minutes: number): string {
const mins = Math.floor(minutes);
const secs = Math.round((minutes - mins) * 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}