mirror of
https://github.com/jhbruhn/respira.git
synced 2026-03-13 18:28:41 +00:00
Compare commits
7 commits
60762d1526
...
a275f72311
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a275f72311 | ||
| 38afe33826 | |||
| a6868ae5ec | |||
| bc46fe0015 | |||
| f2d05c2714 | |||
| 467eb9df95 | |||
| c81930d1b7 |
13 changed files with 693 additions and 934 deletions
422
src/App.tsx
422
src/App.tsx
|
|
@ -1,18 +1,13 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } 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 { FileUpload } from './components/FileUpload';
|
import { AppHeader } from './components/AppHeader';
|
||||||
|
import { LeftSidebar } from './components/LeftSidebar';
|
||||||
import { PatternCanvas } from './components/PatternCanvas';
|
import { PatternCanvas } from './components/PatternCanvas';
|
||||||
import { ProgressMonitor } from './components/ProgressMonitor';
|
import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder';
|
||||||
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() {
|
||||||
|
|
@ -20,442 +15,69 @@ 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 {
|
||||||
isConnected,
|
|
||||||
machineInfo,
|
|
||||||
machineStatus,
|
|
||||||
machineStatusName,
|
|
||||||
machineError,
|
|
||||||
patternInfo,
|
|
||||||
error: machineErrorMessage,
|
|
||||||
isPairingError,
|
|
||||||
isCommunicating: isPolling,
|
|
||||||
resumeFileName,
|
|
||||||
resumedPattern,
|
resumedPattern,
|
||||||
connect,
|
resumeFileName,
|
||||||
disconnect,
|
|
||||||
} = useMachineStore(
|
} = useMachineStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
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,
|
|
||||||
resumedPattern: state.resumedPattern,
|
resumedPattern: state.resumedPattern,
|
||||||
connect: state.connect,
|
resumeFileName: state.resumeFileName,
|
||||||
disconnect: state.disconnect,
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pattern store
|
// Pattern store - for auto-loading cached pattern
|
||||||
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
|
// UI store - for Pyodide initialization
|
||||||
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]);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Auto-load cached pattern when available
|
// Auto-load cached pattern when available
|
||||||
if (resumedPattern && !pesData) {
|
useEffect(() => {
|
||||||
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
|
if (resumedPattern && !pesData) {
|
||||||
setPattern(resumedPattern.pesData, resumeFileName || '');
|
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
|
||||||
// Restore the cached pattern offset
|
setPattern(resumedPattern.pesData, resumeFileName || '');
|
||||||
if (resumedPattern.patternOffset) {
|
// Restore the cached pattern offset
|
||||||
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
|
if (resumedPattern.patternOffset) {
|
||||||
|
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
|
||||||
|
|
||||||
|
|
||||||
// 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">
|
||||||
<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">
|
<AppHeader />
|
||||||
<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 */}
|
||||||
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
|
<LeftSidebar />
|
||||||
{/* 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 ? (
|
{pesData ? <PatternCanvas /> : <PatternPreviewPlaceholder />}
|
||||||
<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>
|
||||||
|
|
||||||
|
|
|
||||||
207
src/components/AppHeader.tsx
Normal file
207
src/components/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/ConnectionPrompt.tsx
Normal file
67
src/components/ConnectionPrompt.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/ErrorPopover.tsx
Normal file
84
src/components/ErrorPopover.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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';
|
||||||
|
|
@ -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 } from '../stores/useMachineStore';
|
import { useMachineStore, usePatternUploaded } 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,18 +40,19 @@ 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,
|
||||||
|
|
|
||||||
44
src/components/LeftSidebar.tsx
Normal file
44
src/components/LeftSidebar.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 } from '../stores/useMachineStore';
|
import { useMachineStore, usePatternUploaded } 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,16 +27,17 @@ 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);
|
||||||
|
|
||||||
|
|
|
||||||
46
src/components/PatternPreviewPlaceholder.tsx
Normal file
46
src/components/PatternPreviewPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ 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
|
||||||
|
|
@ -56,8 +57,15 @@ export function ProgressMonitor() {
|
||||||
|
|
||||||
const stateVisual = getStateVisualInfo(machineStatus);
|
const stateVisual = getStateVisualInfo(machineStatus);
|
||||||
|
|
||||||
const progressPercent = patternInfo
|
// Use PEN stitch count as fallback when machine reports 0 total stitches
|
||||||
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
|
const totalStitches = patternInfo
|
||||||
|
? (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
|
||||||
|
|
@ -102,6 +110,15 @@ 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) {
|
||||||
|
|
@ -173,16 +190,15 @@ 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">
|
||||||
{patternInfo.totalStitches.toLocaleString()}
|
{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">
|
||||||
Est. Time
|
Total 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">
|
||||||
{Math.floor(patternInfo.totalTime / 60)}:
|
{totalMinutes} min
|
||||||
{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">
|
||||||
|
|
@ -213,16 +229,15 @@ 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()} /{" "}
|
||||||
{patternInfo?.totalStitches.toLocaleString() || 0}
|
{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">
|
||||||
Time Elapsed
|
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">
|
||||||
{Math.floor(sewingProgress.currentTime / 60)}:
|
{elapsedMinutes} / {totalMinutes} min
|
||||||
{String(sewingProgress.currentTime % 60).padStart(2, "0")}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 } from '../stores/useMachineStore';
|
import { useMachineStore, usePatternUploaded } 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);
|
||||||
|
|
|
||||||
|
|
@ -1,457 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { create } from 'zustand';
|
import { create } from "zustand";
|
||||||
import { BrotherPP1Service, BluetoothPairingError } from '../services/BrotherPP1Service';
|
import {
|
||||||
|
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
|
||||||
|
|
@ -37,7 +40,10 @@ interface MachineState {
|
||||||
// Resume state
|
// Resume state
|
||||||
resumeAvailable: boolean;
|
resumeAvailable: boolean;
|
||||||
resumeFileName: string | null;
|
resumeFileName: string | null;
|
||||||
resumedPattern: { pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null;
|
resumedPattern: {
|
||||||
|
pesData: PesPatternData;
|
||||||
|
patternOffset?: { x: number; y: number };
|
||||||
|
} | null;
|
||||||
|
|
||||||
// Error state
|
// Error state
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
@ -62,14 +68,17 @@ 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<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null>;
|
loadCachedPattern: () => Promise<{
|
||||||
|
pesData: PesPatternData;
|
||||||
|
patternOffset?: { x: number; y: number };
|
||||||
|
} | null>;
|
||||||
|
|
||||||
// Internal methods
|
// Internal methods
|
||||||
_setupSubscriptions: () => void;
|
_setupSubscriptions: () => void;
|
||||||
|
|
@ -84,7 +93,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,
|
||||||
|
|
@ -104,16 +113,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;
|
||||||
}
|
}
|
||||||
|
|
@ -122,31 +131,39 @@ 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('[Resume] Pattern found in cache:', cached.fileName, 'Offset:', cached.patternOffset);
|
console.log(
|
||||||
console.log('[Resume] Auto-loading cached pattern...');
|
"[Resume] Pattern found in cache:",
|
||||||
|
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: { pesData: cached.pesData, patternOffset: cached.patternOffset },
|
resumedPattern: {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +185,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -182,7 +199,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +216,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,
|
||||||
|
|
@ -207,7 +224,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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -221,12 +238,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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -241,7 +258,8 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
||||||
set({ patternInfo: info });
|
set({ patternInfo: info });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to get pattern info',
|
error:
|
||||||
|
err instanceof Error ? err.message : "Failed to get pattern info",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -256,7 +274,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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -276,7 +294,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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -285,11 +303,17 @@ 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 { isConnected, service, storageService, refreshStatus, refreshPatternInfo } = get();
|
const {
|
||||||
|
isConnected,
|
||||||
|
service,
|
||||||
|
storageService,
|
||||||
|
refreshStatus,
|
||||||
|
refreshPatternInfo,
|
||||||
|
} = get();
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
set({ error: 'Not connected to machine' });
|
set({ error: "Not connected to machine" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,7 +334,14 @@ 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('[Cache] Saved pattern:', fileName, 'with UUID:', uuidStr, 'Offset:', patternOffset);
|
console.log(
|
||||||
|
"[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({
|
||||||
|
|
@ -323,7 +354,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 });
|
||||||
|
|
@ -341,7 +372,8 @@ 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 mask trace',
|
error:
|
||||||
|
err instanceof Error ? err.message : "Failed to start mask trace",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -357,7 +389,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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -373,7 +405,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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -392,10 +424,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();
|
||||||
|
|
@ -412,7 +444,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 });
|
||||||
|
|
@ -420,8 +452,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
// Load cached pattern
|
// Load cached pattern
|
||||||
loadCachedPattern: async (): Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null> => {
|
loadCachedPattern: async (): Promise<{
|
||||||
const { resumeAvailable, service, storageService, refreshPatternInfo } = get();
|
pesData: PesPatternData;
|
||||||
|
patternOffset?: { x: number; y: number };
|
||||||
|
} | null> => {
|
||||||
|
const { resumeAvailable, service, storageService, refreshPatternInfo } =
|
||||||
|
get();
|
||||||
if (!resumeAvailable) return null;
|
if (!resumeAvailable) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -432,7 +468,12 @@ 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('[Resume] Loading cached pattern:', cached.fileName, 'Offset:', cached.patternOffset);
|
console.log(
|
||||||
|
"[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 };
|
||||||
}
|
}
|
||||||
|
|
@ -440,7 +481,8 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to load cached pattern',
|
error:
|
||||||
|
err instanceof Error ? err.message : "Failed to load cached pattern",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -457,17 +499,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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -475,7 +517,13 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
||||||
|
|
||||||
// Start polling for status updates
|
// Start polling for status updates
|
||||||
_startPolling: () => {
|
_startPolling: () => {
|
||||||
const { _stopPolling, refreshStatus, refreshProgress, refreshServiceCount } = get();
|
const {
|
||||||
|
_stopPolling,
|
||||||
|
refreshStatus,
|
||||||
|
refreshProgress,
|
||||||
|
refreshServiceCount,
|
||||||
|
refreshPatternInfo,
|
||||||
|
} = get();
|
||||||
|
|
||||||
// Stop any existing polling
|
// Stop any existing polling
|
||||||
_stopPolling();
|
_stopPolling();
|
||||||
|
|
@ -510,6 +558,11 @@ 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);
|
||||||
|
|
@ -546,9 +599,18 @@ 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 = () => useMachineStore((state) => state.isConnected);
|
export const useIsConnected = () =>
|
||||||
export const useMachineInfo = () => useMachineStore((state) => state.machineInfo);
|
useMachineStore((state) => state.isConnected);
|
||||||
export const useMachineStatus = () => useMachineStore((state) => state.machineStatus);
|
export const useMachineInfo = () =>
|
||||||
export const useMachineError = () => useMachineStore((state) => state.machineError);
|
useMachineStore((state) => state.machineInfo);
|
||||||
export const usePatternInfo = () => useMachineStore((state) => state.patternInfo);
|
export const useMachineStatus = () =>
|
||||||
export const useSewingProgress = () => useMachineStore((state) => state.sewingProgress);
|
useMachineStore((state) => state.machineStatus);
|
||||||
|
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);
|
||||||
|
|
|
||||||
67
src/utils/timeCalculation.ts
Normal file
67
src/utils/timeCalculation.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* 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')}`;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue