Compare commits

...

7 commits

Author SHA1 Message Date
Jan-Henrik Bruhn
a275f72311
Merge pull request #19 from jhbruhn/fix/fallback-to-pen-stitch-count
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
fix: Fall back to PEN stitch count when machine reports 0 total stitches, fix remaining time calculation
2025-12-17 12:28:29 +01:00
38afe33826 style: Apply linter formatting to useMachineStore
Auto-formatted by linter:
- Single quotes → double quotes
- Line wrapping for better readability

No logic changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 12:27:34 +01:00
a6868ae5ec chore: Remove unused useBrotherMachine hook
This hook was replaced by useMachineStore (Zustand) and is no longer
used anywhere in the codebase. All functionality has been migrated to
the centralized machine store.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 12:26:49 +01:00
bc46fe0015 fix: Calculate time correctly per color block using Brother formula
Implemented proper time calculation matching the Brother app algorithm:
- 150ms per stitch + 3000ms startup time per color block
- Calculate total and elapsed time by summing across color blocks
- This fixes the "999 seconds" issue by calculating time accurately

Created timeCalculation utility with:
- convertStitchesToMinutes: Convert stitches to minutes using PP1 formula
- calculatePatternTime: Calculate total/elapsed time per color blocks

Updated ProgressMonitor to show:
- Total Time (calculated from all color blocks)
- Elapsed Time / Total Time (based on current stitch position)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 12:19:24 +01:00
f2d05c2714 fix: Fall back to PEN stitch count when machine reports 0 total stitches
When the machine reports 0 total stitches in patternInfo, fall back to
using the PEN data stitch count (penStitches.stitches.length) for UI
display. This ensures progress percentage and stitch counts display
correctly even when the machine hasn't fully initialized pattern info.

Updated ProgressMonitor to use derived totalStitches value that prefers
patternInfo.totalStitches but falls back to PEN data when needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 12:14:41 +01:00
467eb9df95 refactor: Derive patternUploaded from patternInfo instead of syncing state
Removed redundant patternUploaded state from PatternStore and replaced
it with a derived selector usePatternUploaded() in MachineStore that
computes it from patternInfo !== null.

This eliminates duplicate state, removes the need for synchronization
logic, and ensures a single source of truth for pattern upload status.

Updated all components (App, LeftSidebar, FileUpload, PatternCanvas,
WorkflowStepper) to use the derived selector.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 11:25:26 +01:00
c81930d1b7 refactor: Break down App.tsx into focused subcomponents
Extracted App.tsx (469 lines → 91 lines) into 5 new components:
- ErrorPopover: Displays error details with solutions (84 lines)
- AppHeader: Machine status, workflow stepper, errors (207 lines)
- ConnectionPrompt: Connect button or browser warning (67 lines)
- LeftSidebar: Conditional rendering of controls (42 lines)
- PatternPreviewPlaceholder: Empty state for preview (46 lines)

This improves code organization, maintainability, and reusability.
Each component now has a single, clear responsibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 11:16:59 +01:00
13 changed files with 693 additions and 934 deletions

View file

@ -1,18 +1,13 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from './stores/useMachineStore';
import { usePatternStore } from './stores/usePatternStore';
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 { ProgressMonitor } from './components/ProgressMonitor';
import { WorkflowStepper } from './components/WorkflowStepper';
import { PatternSummaryCard } from './components/PatternSummaryCard';
import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder';
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';
function App() {
@ -20,442 +15,69 @@ function App() {
useEffect(() => {
document.title = `Respira v${__APP_VERSION__}`;
}, []);
// Machine store
// Machine store - for auto-loading cached pattern
const {
isConnected,
machineInfo,
machineStatus,
machineStatusName,
machineError,
patternInfo,
error: machineErrorMessage,
isPairingError,
isCommunicating: isPolling,
resumeFileName,
resumedPattern,
connect,
disconnect,
resumeFileName,
} = useMachineStore(
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,
connect: state.connect,
disconnect: state.disconnect,
resumeFileName: state.resumeFileName,
}))
);
// Pattern store
// Pattern store - for auto-loading cached pattern
const {
pesData,
patternUploaded,
setPattern,
setPatternOffset,
setPatternUploaded,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
patternUploaded: state.patternUploaded,
setPattern: state.setPattern,
setPatternOffset: state.setPatternOffset,
setPatternUploaded: state.setPatternUploaded,
}))
);
// UI store
// UI store - for Pyodide initialization
const {
pyodideError,
showErrorPopover,
initializePyodide,
setErrorPopover,
} = useUIStore(
useShallow((state) => ({
pyodideError: state.pyodideError,
showErrorPopover: state.showErrorPopover,
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)
useEffect(() => {
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
if (resumedPattern && !pesData) {
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
setPattern(resumedPattern.pesData, resumeFileName || '');
// Restore the cached pattern offset
if (resumedPattern.patternOffset) {
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
useEffect(() => {
if (resumedPattern && !pesData) {
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
setPattern(resumedPattern.pesData, resumeFileName || '');
// Restore the cached pattern offset
if (resumedPattern.patternOffset) {
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
}
}
}
// Track pattern uploaded state based on machine status
if (!isConnected) {
if (patternUploaded) {
setPatternUploaded(false);
}
} else {
// Pattern is uploaded if machine has pattern info
const shouldBeUploaded = patternInfo !== null;
if (patternUploaded !== shouldBeUploaded) {
setPatternUploaded(shouldBeUploaded);
}
}
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = {
ready: CheckCircleIcon,
active: BoltIcon,
waiting: PauseCircleIcon,
complete: CheckCircleIcon,
interrupted: PauseCircleIcon,
error: ExclamationTriangleIcon,
};
const StatusIcon = stateIcons[stateVisual.iconName];
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
return (
<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">
<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>
<AppHeader />
<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">
{/* Left Column - Controls */}
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
{/* Connect Button or Browser Hint - Show when disconnected */}
{!isConnected && (
<>
{isBluetoothSupported() ? (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p>
</div>
</div>
<button
onClick={connect}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
>
Connect to Machine
</button>
</div>
) : (
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg shadow-md border-l-4 border-amber-500 dark:border-amber-600">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-amber-900 dark:text-amber-100 mb-2">Browser Not Supported</h3>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-3">
Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
</p>
<div className="space-y-2">
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">Please try one of these options:</p>
<ul className="text-sm text-amber-800 dark:text-amber-200 space-y-1.5 ml-4 list-disc">
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
<li>
Download the Desktop app from{' '}
<a
href="https://github.com/jhbruhn/respira/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="font-semibold underline hover:text-amber-900 dark:hover:text-amber-50 transition-colors"
>
GitHub Releases
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
)}
</>
)}
{/* Pattern File - Show during upload stage (before pattern is uploaded) */}
{isConnected && !patternUploaded && (
<FileUpload />
)}
{/* Compact Pattern Summary - Show after upload (during sewing stages) */}
{isConnected && patternUploaded && pesData && (
<PatternSummaryCard />
)}
{/* Progress Monitor - Show when pattern is uploaded */}
{isConnected && patternUploaded && (
<div className="lg:flex-1 lg:min-h-0">
<ProgressMonitor />
</div>
)}
</div>
<LeftSidebar />
{/* Right Column - Pattern Preview */}
<div className="flex flex-col lg:overflow-hidden lg:h-full">
{pesData ? (
<PatternCanvas />
) : (
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2>
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
{/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10">
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
</div>
<div className="text-center relative z-10">
<div className="relative inline-block mb-6">
<svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
</div>
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
Connect to your machine and choose a PES embroidery file to see your design preview
</p>
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></div>
<span>Drag to Position</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-400 dark:bg-green-500 rounded-full"></div>
<span>Zoom & Pan</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-purple-400 dark:bg-purple-500 rounded-full"></div>
<span>Real-time Preview</span>
</div>
</div>
</div>
</div>
</div>
)}
{pesData ? <PatternCanvas /> : <PatternPreviewPlaceholder />}
</div>
</div>

View 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>
);
}

View 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>
);
}

View 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';

View file

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

View 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>
);
}

View file

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

View 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>
);
}

View file

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

View file

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

View file

@ -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,
};
}

View file

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

View 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')}`;
}