This commit is contained in:
Jan-Henrik Bruhn 2025-11-30 22:18:14 +01:00
commit acdf87b237
32 changed files with 7202 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

150
README.md Normal file
View file

@ -0,0 +1,150 @@
# Brother Embroidery Machine Web Controller
A modern web application for controlling Brother embroidery machines via WebBluetooth.
## Features
- **WebBluetooth Connection**: Connect directly to Brother PP1 embroidery machines from your browser
- **Pattern Upload**: Load and upload PEN format embroidery files
- **Pattern Visualization**: Preview embroidery patterns on an interactive canvas with color information
- **Real-time Monitoring**: Monitor sewing progress, position, and status in real-time
- **Machine Control**: Start mask trace, start sewing, and manage patterns
## Requirements
- Modern browser with WebBluetooth support (Chrome, Edge, Opera)
- HTTPS connection (required for WebBluetooth API)
- Brother PP1 compatible embroidery machine with BLE
## Getting Started
### Installation
```bash
npm install
```
### Development
```bash
npm run dev
```
The application will be available at `http://localhost:5173`
**Note**: WebBluetooth requires HTTPS. For local development, you can use:
- `localhost` (works with HTTP)
- A reverse proxy with SSL
- Vite's HTTPS mode: `npm run dev -- --https`
### Build for Production
```bash
npm run build
```
The built files will be in the `dist` directory.
### Preview Production Build
```bash
npm run preview
```
## Usage
1. **Connect to Machine**
- Click "Connect to Machine"
- Select your Brother embroidery machine from the browser's Bluetooth device picker
- Machine information and status will be displayed
2. **Load Pattern**
- Click "Choose PEN File" and select a `.pen` embroidery file
- Pattern details and preview will be shown on the canvas
- Different colors are displayed in the preview
3. **Upload to Machine**
- Click "Upload to Machine" to transfer the pattern
- Upload progress will be shown
- Pattern information will be retrieved from the machine
4. **Start Mask Trace** (optional)
- Click "Start Mask Trace" to trace the pattern outline
- Confirm on the machine when prompted
5. **Start Sewing**
- Click "Start Sewing" to begin the embroidery process
- Real-time progress, position, and status will be displayed
- Follow machine prompts for color changes
6. **Monitor Progress**
- View current stitch count and completion percentage
- See real-time needle position
- Track elapsed time
## Project Structure
```
web/
├── src/
│ ├── components/ # React components
│ │ ├── MachineConnection.tsx
│ │ ├── FileUpload.tsx
│ │ ├── PatternCanvas.tsx
│ │ └── ProgressMonitor.tsx
│ ├── hooks/ # Custom React hooks
│ │ └── useBrotherMachine.ts
│ ├── services/ # BLE communication
│ │ └── BrotherPP1Service.ts
│ ├── types/ # TypeScript types
│ │ └── machine.ts
│ ├── utils/ # Utility functions
│ │ └── penParser.ts
│ ├── App.tsx # Main application component
│ ├── App.css # Application styles
│ └── main.tsx # Entry point
├── public/ # Static assets
├── package.json
├── tsconfig.json
└── vite.config.ts
```
## Technology Stack
- **React 18**: UI framework
- **TypeScript**: Type safety
- **Vite**: Build tool and dev server
- **WebBluetooth API**: BLE communication
- **HTML5 Canvas**: Pattern visualization
## Protocol
The application implements the Brother PP1 BLE protocol:
- Service UUID: `a76eb9e0-f3ac-4990-84cf-3a94d2426b2b`
- Write Characteristic: `a76eb9e2-f3ac-4990-84cf-3a94d2426b2b`
- Read Characteristic: `a76eb9e1-f3ac-4990-84cf-3a94d2426b2b`
See `../emulator/PROTOCOL.md` for detailed protocol documentation.
## PEN Format
The application supports PEN format embroidery files:
- Binary format with 4-byte stitch records
- Coordinates in 0.1mm units
- Supports multiple colors and color changes
- Includes jump stitches and flags
## Browser Compatibility
WebBluetooth is supported in:
- Chrome 56+
- Edge 79+
- Opera 43+
**Not supported in:**
- Firefox (no WebBluetooth support)
- Safari (no WebBluetooth support)
## License
MIT

23
eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3223
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"pyodide": "^0.27.4"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vite-plugin-static-copy": "^3.1.4"
}
}

Binary file not shown.

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

645
src/App.css Normal file
View file

@ -0,0 +1,645 @@
:root {
--primary-color: #0066cc;
--secondary-color: #6c757d;
--danger-color: #dc3545;
--success-color: #28a745;
--warning-color: #ffc107;
--info-color: #17a2b8;
--background: #f5f5f5;
--panel-background: #ffffff;
--border-color: #dee2e6;
--text-color: #212529;
--text-muted: #6c757d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--background);
color: var(--text-color);
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background-color: var(--panel-background);
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 1rem;
border: 1px solid #f5c6cb;
}
.app-content {
flex: 1;
display: grid;
grid-template-columns: 400px 1fr;
gap: 1.5rem;
padding: 1.5rem;
max-width: 1600px;
width: 100%;
margin: 0 auto;
}
.left-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.right-panel {
display: flex;
flex-direction: column;
}
.connection-panel,
.file-upload-panel,
.progress-panel,
.canvas-panel {
background-color: var(--panel-background);
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
}
h3 {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-row .label {
font-weight: 500;
color: var(--text-muted);
}
.detail-row .value {
font-weight: 600;
}
.status-bar {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background-color: var(--background);
border-radius: 4px;
}
.status-indicator {
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
}
.status-indicator.status-16 { /* IDLE */
background-color: #d1ecf1;
color: #0c5460;
}
.status-indicator.status-17 { /* SEWING_WAIT */
background-color: #d4edda;
color: #155724;
}
.status-indicator.status-48 { /* SEWING */
background-color: #fff3cd;
color: #856404;
}
.status-indicator.status-49 { /* SEWING_COMPLETE */
background-color: #d4edda;
color: #155724;
}
.status-indicator.status-64 { /* COLOR_CHANGE_WAIT */
background-color: #fff3cd;
color: #856404;
}
.error-indicator {
background-color: #f8d7da;
color: #721c24;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
}
.polling-indicator {
color: var(--primary-color);
font-size: 0.75rem;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.connection-actions,
.progress-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
flex-wrap: wrap;
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0052a3;
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #5a6268;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: #c82333;
}
.file-input {
display: none;
}
.progress-bar {
height: 8px;
background-color: var(--border-color);
border-radius: 4px;
overflow: hidden;
margin: 1rem 0;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s;
}
.pattern-canvas {
width: 100%;
height: 600px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: #fafafa;
}
.canvas-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 600px;
color: var(--text-muted);
font-style: italic;
}
.status-message {
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
font-weight: 500;
}
.status-message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
.status-message.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Color Block Progress Styles */
.color-blocks {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.color-block-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.color-block-item {
padding: 0.75rem;
border-radius: 4px;
background-color: var(--background);
border: 2px solid transparent;
transition: all 0.3s;
}
.color-block-item.completed {
border-color: var(--success-color);
background-color: #f0f9f4;
}
.color-block-item.current {
border-color: var(--primary-color);
background-color: #e7f3ff;
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.2);
}
.color-block-item.pending {
opacity: 0.6;
}
.block-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid var(--border-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.block-label {
font-weight: 600;
flex: 1;
}
.block-status {
font-size: 1.25rem;
font-weight: bold;
color: var(--text-muted);
}
.color-block-item.completed .block-status {
color: var(--success-color);
}
.color-block-item.current .block-status {
color: var(--primary-color);
}
.block-stitches {
font-size: 0.875rem;
color: var(--text-muted);
}
.block-progress-bar {
margin-top: 0.5rem;
height: 4px;
background-color: #fff;
border-radius: 2px;
overflow: hidden;
}
.block-progress-fill {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s;
}
@media (max-width: 1024px) {
.app-content {
grid-template-columns: 1fr;
}
}
/* ==========================================================================
State-Based UX Safety Styles
========================================================================== */
/* Confirmation Dialog Styles */
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background-color: var(--panel-background);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
margin: 1rem;
}
.confirm-dialog-danger {
border-top: 4px solid #dc3545;
}
.confirm-dialog-warning {
border-top: 4px solid #ffc107;
}
.confirm-dialog-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.confirm-dialog-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.confirm-dialog-body {
padding: 1.5rem;
}
.confirm-dialog-body p {
margin: 0;
line-height: 1.5;
color: var(--text-color);
}
.confirm-dialog-actions {
padding: 1rem 1.5rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
border-top: 1px solid var(--border-color);
}
/* Enhanced Status Badge */
.status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
}
.status-badge-idle,
.status-badge-info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.status-badge-active,
.status-badge-waiting,
.status-badge-warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
.status-badge-complete,
.status-badge-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-badge-interrupted,
.status-badge-error,
.status-badge-danger {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-icon {
font-size: 1.1rem;
line-height: 1;
}
.status-text {
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* State Indicator Component */
.state-indicator {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
border-left: 4px solid transparent;
}
.state-indicator-idle,
.state-indicator-info {
background-color: #e7f3ff;
border-left-color: #0066cc;
}
.state-indicator-active,
.state-indicator-waiting,
.state-indicator-warning {
background-color: #fff8e1;
border-left-color: #ffc107;
}
.state-indicator-complete,
.state-indicator-success {
background-color: #e8f5e9;
border-left-color: #28a745;
}
.state-indicator-interrupted,
.state-indicator-error,
.state-indicator-danger {
background-color: #ffebee;
border-left-color: #dc3545;
}
.state-icon {
font-size: 2rem;
line-height: 1;
}
.state-info {
flex: 1;
}
.state-label {
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.25rem;
}
.state-description {
font-size: 0.875rem;
color: #666;
}
/* Enhanced Progress Visualization */
.progress-bar {
height: 12px;
background-color: var(--border-color);
border-radius: 6px;
overflow: hidden;
margin: 1rem 0;
position: relative;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), #0052a3);
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* Button enhancements for safety */
button.btn-danger:hover:not(:disabled) {
background-color: #c82333;
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
transform: translateY(-1px);
}
button.btn-primary:hover:not(:disabled) {
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.3);
transform: translateY(-1px);
}
button.btn-secondary:hover:not(:disabled) {
box-shadow: 0 2px 8px rgba(108, 117, 125, 0.3);
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: translateY(0);
}
/* Info message styles */
.status-message.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Enhanced visual feedback for disabled state */
button:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(0.3);
}

107
src/App.tsx Normal file
View file

@ -0,0 +1,107 @@
import { useState, useEffect } from 'react';
import { useBrotherMachine } from './hooks/useBrotherMachine';
import { MachineConnection } from './components/MachineConnection';
import { FileUpload } from './components/FileUpload';
import { PatternCanvas } from './components/PatternCanvas';
import { ProgressMonitor } from './components/ProgressMonitor';
import type { PesPatternData } from './utils/pystitchConverter';
import { pyodideLoader } from './utils/pyodideLoader';
import './App.css';
function App() {
const machine = useBrotherMachine();
const [pesData, setPesData] = useState<PesPatternData | null>(null);
const [pyodideReady, setPyodideReady] = useState(false);
const [pyodideError, setPyodideError] = useState<string | null>(null);
// Initialize Pyodide on mount
useEffect(() => {
pyodideLoader
.initialize()
.then(() => {
setPyodideReady(true);
console.log('[App] Pyodide initialized successfully');
})
.catch((err) => {
setPyodideError(err instanceof Error ? err.message : 'Failed to initialize Python environment');
console.error('[App] Failed to initialize Pyodide:', err);
});
}, []);
// Auto-load cached pattern when available
useEffect(() => {
if (machine.resumedPattern && !pesData) {
console.log('[App] Loading resumed pattern:', machine.resumeFileName);
setPesData(machine.resumedPattern);
}
}, [machine.resumedPattern, pesData, machine.resumeFileName]);
const handlePatternLoaded = (data: PesPatternData) => {
setPesData(data);
};
return (
<div className="app">
<header className="app-header">
<h1>Brother Embroidery Machine Controller</h1>
{machine.error && (
<div className="error-message">{machine.error}</div>
)}
{pyodideError && (
<div className="error-message">Python Error: {pyodideError}</div>
)}
{!pyodideReady && !pyodideError && (
<div className="info-message">Initializing Python environment...</div>
)}
</header>
<div className="app-content">
<div className="left-panel">
<MachineConnection
isConnected={machine.isConnected}
machineInfo={machine.machineInfo}
machineStatus={machine.machineStatus}
machineStatusName={machine.machineStatusName}
machineError={machine.machineError}
isPolling={machine.isPolling}
resumeAvailable={machine.resumeAvailable}
resumeFileName={machine.resumeFileName}
onConnect={machine.connect}
onDisconnect={machine.disconnect}
onRefresh={machine.refreshStatus}
/>
<FileUpload
isConnected={machine.isConnected}
machineStatus={machine.machineStatus}
uploadProgress={machine.uploadProgress}
onPatternLoaded={handlePatternLoaded}
onUpload={machine.uploadPattern}
pyodideReady={pyodideReady}
/>
<ProgressMonitor
machineStatus={machine.machineStatus}
patternInfo={machine.patternInfo}
sewingProgress={machine.sewingProgress}
pesData={pesData}
onStartMaskTrace={machine.startMaskTrace}
onStartSewing={machine.startSewing}
onResumeSewing={machine.resumeSewing}
onDeletePattern={machine.deletePattern}
/>
</div>
<div className="right-panel">
<PatternCanvas
pesData={pesData}
sewingProgress={machine.sewingProgress}
machineInfo={machine.machineInfo}
/>
</div>
</div>
</div>
);
}
export default App;

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,73 @@
import { useEffect, useCallback } from 'react';
interface ConfirmDialogProps {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
variant?: 'danger' | 'warning';
}
export function ConfirmDialog({
isOpen,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'warning',
}: ConfirmDialogProps) {
// Handle escape key
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
}
}, [onCancel]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div
className={`confirm-dialog confirm-dialog-${variant}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-message"
>
<div className="confirm-dialog-header">
<h3 id="dialog-title">{title}</h3>
</div>
<div className="confirm-dialog-body">
<p id="dialog-message">{message}</p>
</div>
<div className="confirm-dialog-actions">
<button
onClick={onCancel}
className="btn-secondary"
autoFocus
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
>
{confirmText}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,136 @@
import { useState, useCallback } from 'react';
import { convertPesToPen, type PesPatternData } from '../utils/pystitchConverter';
import { MachineStatus } from '../types/machine';
import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
interface FileUploadProps {
isConnected: boolean;
machineStatus: MachineStatus;
uploadProgress: number;
onPatternLoaded: (pesData: PesPatternData) => void;
onUpload: (penData: Uint8Array, pesData: PesPatternData, fileName: string) => void;
pyodideReady: boolean;
}
export function FileUpload({
isConnected,
machineStatus,
uploadProgress,
onPatternLoaded,
onUpload,
pyodideReady,
}: FileUploadProps) {
const [pesData, setPesData] = useState<PesPatternData | null>(null);
const [fileName, setFileName] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!pyodideReady) {
alert('Python environment is still loading. Please wait...');
return;
}
setIsLoading(true);
try {
const data = await convertPesToPen(file);
setPesData(data);
setFileName(file.name);
onPatternLoaded(data);
} catch (err) {
alert(
`Failed to load PES file: ${
err instanceof Error ? err.message : 'Unknown error'
}`
);
} finally {
setIsLoading(false);
}
},
[onPatternLoaded, pyodideReady]
);
const handleUpload = useCallback(() => {
if (pesData && fileName) {
onUpload(pesData.penData, pesData, fileName);
}
}, [pesData, fileName, onUpload]);
return (
<div className="file-upload-panel">
<h2>Pattern File</h2>
<div className="upload-controls">
<input
type="file"
accept=".pes"
onChange={handleFileChange}
id="file-input"
className="file-input"
disabled={!pyodideReady || isLoading}
/>
<label htmlFor="file-input" className={`btn-secondary ${!pyodideReady || isLoading ? 'disabled' : ''}`}>
{isLoading ? 'Loading...' : !pyodideReady ? 'Initializing...' : 'Choose PES File'}
</label>
{pesData && (
<div className="pattern-info">
<h3>Pattern Details</h3>
<div className="detail-row">
<span className="label">Total Stitches:</span>
<span className="value">{pesData.stitchCount}</span>
</div>
<div className="detail-row">
<span className="label">Colors:</span>
<span className="value">{pesData.colorCount}</span>
</div>
<div className="detail-row">
<span className="label">Size:</span>
<span className="value">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span>
</div>
<div className="detail-row">
<span className="label">Bounds:</span>
<span className="value">
({pesData.bounds.minX}, {pesData.bounds.minY}) to (
{pesData.bounds.maxX}, {pesData.bounds.maxY})
</span>
</div>
</div>
)}
{pesData && canUploadPattern(machineStatus) && (
<button
onClick={handleUpload}
disabled={!isConnected || uploadProgress > 0}
className="btn-primary"
>
{uploadProgress > 0
? `Uploading... ${uploadProgress.toFixed(0)}%`
: 'Upload to Machine'}
</button>
)}
{pesData && !canUploadPattern(machineStatus) && (
<div className="status-message warning">
Cannot upload pattern while machine is {getMachineStateCategory(machineStatus)}
</div>
)}
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
import { useState } from 'react';
import type { MachineInfo } from '../types/machine';
import { MachineStatus } from '../types/machine';
import { ConfirmDialog } from './ConfirmDialog';
import { shouldConfirmDisconnect, getStateVisualInfo } from '../utils/machineStateHelpers';
import { hasError, getErrorMessage } from '../utils/errorCodeHelpers';
interface MachineConnectionProps {
isConnected: boolean;
machineInfo: MachineInfo | null;
machineStatus: MachineStatus;
machineStatusName: string;
machineError: number;
isPolling: boolean;
resumeAvailable: boolean;
resumeFileName: string | null;
onConnect: () => void;
onDisconnect: () => void;
onRefresh: () => void;
}
export function MachineConnection({
isConnected,
machineInfo,
machineStatus,
machineStatusName,
machineError,
isPolling,
resumeAvailable,
resumeFileName,
onConnect,
onDisconnect,
onRefresh,
}: MachineConnectionProps) {
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
const handleDisconnectClick = () => {
if (shouldConfirmDisconnect(machineStatus)) {
setShowDisconnectConfirm(true);
} else {
onDisconnect();
}
};
const handleConfirmDisconnect = () => {
setShowDisconnectConfirm(false);
onDisconnect();
};
const stateVisual = getStateVisualInfo(machineStatus);
return (
<div className="connection-panel">
<h2>Machine Connection</h2>
{!isConnected ? (
<div className="connection-actions">
<button onClick={onConnect} className="btn-primary">
Connect to Machine
</button>
</div>
) : (
<div className="connection-info">
<div className="status-bar">
<span className={`status-badge status-badge-${stateVisual.color}`}>
<span className="status-icon">{stateVisual.icon}</span>
<span className="status-text">{machineStatusName}</span>
</span>
{isPolling && (
<span className="polling-indicator" title="Polling machine status"></span>
)}
{hasError(machineError) && (
<span className="error-indicator">{getErrorMessage(machineError)}</span>
)}
</div>
{machineInfo && (
<div className="machine-details">
<div className="detail-row">
<span className="label">Model:</span>
<span className="value">{machineInfo.modelNumber}</span>
</div>
<div className="detail-row">
<span className="label">Serial:</span>
<span className="value">{machineInfo.serialNumber}</span>
</div>
<div className="detail-row">
<span className="label">Software:</span>
<span className="value">{machineInfo.softwareVersion}</span>
</div>
<div className="detail-row">
<span className="label">Max Area:</span>
<span className="value">
{(machineInfo.maxWidth / 10).toFixed(1)} x{' '}
{(machineInfo.maxHeight / 10).toFixed(1)} mm
</span>
</div>
<div className="detail-row">
<span className="label">MAC:</span>
<span className="value">{machineInfo.macAddress}</span>
</div>
</div>
)}
{resumeAvailable && resumeFileName && (
<div className="status-message success">
Loaded cached pattern: "{resumeFileName}"
</div>
)}
<div className="connection-actions">
<button onClick={onRefresh} className="btn-secondary">
Refresh Status
</button>
<button onClick={handleDisconnectClick} className="btn-danger">
Disconnect
</button>
</div>
</div>
)}
<ConfirmDialog
isOpen={showDisconnectConfirm}
title="Confirm Disconnect"
message={`The machine is currently ${machineStatusName.toLowerCase()}. Disconnecting may interrupt the operation. Are you sure you want to disconnect?`}
confirmText="Disconnect Anyway"
cancelText="Cancel"
onConfirm={handleConfirmDisconnect}
onCancel={() => setShowDisconnectConfirm(false)}
variant="danger"
/>
</div>
);
}

View file

@ -0,0 +1,284 @@
import { useEffect, useRef } from 'react';
import type { PesPatternData } from '../utils/pystitchConverter';
import { getThreadColor } from '../utils/pystitchConverter';
import type { SewingProgress, MachineInfo } from '../types/machine';
interface PatternCanvasProps {
pesData: PesPatternData | null;
sewingProgress: SewingProgress | null;
machineInfo: MachineInfo | null;
}
export function PatternCanvas({ pesData, sewingProgress, machineInfo }: PatternCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current || !pesData) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
const currentStitch = sewingProgress?.currentStitch || 0;
const { stitches, bounds } = pesData;
const { minX, maxX, minY, maxY } = bounds;
const patternWidth = maxX - minX;
const patternHeight = maxY - minY;
const padding = 40;
// Calculate scale based on hoop size if available, otherwise pattern size
let scale: number;
let viewWidth: number;
let viewHeight: number;
if (machineInfo) {
// Use hoop dimensions to determine scale
viewWidth = machineInfo.maxWidth;
viewHeight = machineInfo.maxHeight;
} else {
// Fallback to pattern dimensions
viewWidth = patternWidth;
viewHeight = patternHeight;
}
const scaleX = (canvas.width - 2 * padding) / viewWidth;
const scaleY = (canvas.height - 2 * padding) / viewHeight;
scale = Math.min(scaleX, scaleY);
// Center the view (hoop or pattern) in canvas
// The origin (0,0) should be at the center of the hoop
const offsetX = canvas.width / 2;
const offsetY = canvas.height / 2;
// Draw grid
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
const gridSize = 100; // 10mm grid (100 units in 0.1mm)
// Determine grid bounds based on hoop or pattern
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : minX;
const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : maxX;
const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : minY;
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : maxY;
for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
const canvasX = x * scale + offsetX;
ctx.beginPath();
ctx.moveTo(canvasX, padding);
ctx.lineTo(canvasX, canvas.height - padding);
ctx.stroke();
}
for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
const canvasY = y * scale + offsetY;
ctx.beginPath();
ctx.moveTo(padding, canvasY);
ctx.lineTo(canvas.width - padding, canvasY);
ctx.stroke();
}
// Draw origin
ctx.strokeStyle = '#888';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(offsetX - 10, offsetY);
ctx.lineTo(offsetX + 10, offsetY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(offsetX, offsetY - 10);
ctx.lineTo(offsetX, offsetY + 10);
ctx.stroke();
// Draw hoop boundary (if machine info available)
if (machineInfo) {
// Machine info stores dimensions in 0.1mm units
const hoopWidth = machineInfo.maxWidth;
const hoopHeight = machineInfo.maxHeight;
// Hoop is centered at origin (0, 0)
const hoopLeft = -hoopWidth / 2;
const hoopTop = -hoopHeight / 2;
const hoopRight = hoopWidth / 2;
const hoopBottom = hoopHeight / 2;
// Draw hoop boundary
ctx.strokeStyle = '#2196F3';
ctx.lineWidth = 3;
ctx.setLineDash([10, 5]);
ctx.strokeRect(
hoopLeft * scale + offsetX,
hoopTop * scale + offsetY,
hoopWidth * scale,
hoopHeight * scale
);
// Draw hoop label
ctx.setLineDash([]);
ctx.fillStyle = '#2196F3';
ctx.font = 'bold 14px sans-serif';
ctx.fillText(
`Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`,
hoopLeft * scale + offsetX + 10,
hoopTop * scale + offsetY + 25
);
}
// Draw stitches
// stitches is number[][], each stitch is [x, y, command, colorIndex]
const MOVE = 0x10;
ctx.lineWidth = 1.5;
let lastX = 0;
let lastY = 0;
let threadColor = getThreadColor(pesData, 0);
let currentPosX = 0;
let currentPosY = 0;
for (let i = 0; i < stitches.length; i++) {
const stitch = stitches[i];
const x = stitch[0] * scale + offsetX;
const y = stitch[1] * scale + offsetY;
const cmd = stitch[2];
const colorIndex = stitch[3]; // Color index from PyStitch
// Update thread color based on stitch's color index
threadColor = getThreadColor(pesData, colorIndex);
// Track current position for highlighting
if (i === currentStitch) {
currentPosX = x;
currentPosY = y;
}
if (i > 0) {
const isCompleted = i < currentStitch;
const isCurrent = i === currentStitch;
if ((cmd & MOVE) !== 0) {
// Draw jump as dashed line
ctx.strokeStyle = isCompleted ? '#cccccc' : '#e8e8e8';
ctx.setLineDash([3, 3]);
} else {
// Draw stitch as solid line with actual thread color
// Dim pending stitches
if (isCompleted) {
ctx.strokeStyle = threadColor;
ctx.globalAlpha = 1.0;
} else {
ctx.strokeStyle = threadColor;
ctx.globalAlpha = 0.3;
}
ctx.setLineDash([]);
}
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(x, y);
ctx.stroke();
ctx.globalAlpha = 1.0;
}
lastX = x;
lastY = y;
}
// Draw current position indicator
if (currentStitch > 0 && currentStitch < stitches.length) {
// Draw a pulsing circle at current position
ctx.strokeStyle = '#ff0000';
ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
ctx.lineWidth = 3;
ctx.setLineDash([]);
ctx.beginPath();
ctx.arc(currentPosX, currentPosY, 8, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
// Draw crosshair
ctx.strokeStyle = '#ff0000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(currentPosX - 12, currentPosY);
ctx.lineTo(currentPosX - 3, currentPosY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(currentPosX + 12, currentPosY);
ctx.lineTo(currentPosX + 3, currentPosY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(currentPosX, currentPosY - 12);
ctx.lineTo(currentPosX, currentPosY - 3);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(currentPosX, currentPosY + 12);
ctx.lineTo(currentPosX, currentPosY + 3);
ctx.stroke();
}
// Draw bounds
ctx.strokeStyle = '#ff0000';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.strokeRect(
minX * scale + offsetX,
minY * scale + offsetY,
patternWidth * scale,
patternHeight * scale
);
// Draw color legend using actual thread colors
ctx.setLineDash([]);
let legendY = 20;
// Draw legend for each thread
for (let i = 0; i < pesData.threads.length; i++) {
const color = getThreadColor(pesData, i);
ctx.fillStyle = color;
ctx.fillRect(10, legendY, 20, 20);
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.strokeRect(10, legendY, 20, 20);
ctx.fillStyle = '#000';
ctx.font = '12px sans-serif';
ctx.fillText(
`Thread ${i + 1}`,
35,
legendY + 15
);
legendY += 25;
}
// Draw dimensions
ctx.fillStyle = '#000';
ctx.font = '14px sans-serif';
ctx.fillText(
`${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`,
canvas.width - 120,
canvas.height - 10
);
}, [pesData, sewingProgress, machineInfo]);
return (
<div className="canvas-panel">
<h2>Pattern Preview</h2>
<canvas
ref={canvasRef}
width={800}
height={600}
className="pattern-canvas"
/>
{!pesData && (
<div className="canvas-placeholder">
Load a PES file to preview the pattern
</div>
)}
</div>
);
}

View file

@ -0,0 +1,310 @@
import type { PatternInfo, SewingProgress } from '../types/machine';
import { MachineStatus } from '../types/machine';
import type { PesPatternData } from '../utils/pystitchConverter';
import {
canStartSewing,
canStartMaskTrace,
canDeletePattern,
canResumeSewing,
getStateVisualInfo
} from '../utils/machineStateHelpers';
interface ProgressMonitorProps {
machineStatus: MachineStatus;
patternInfo: PatternInfo | null;
sewingProgress: SewingProgress | null;
pesData: PesPatternData | null;
onStartMaskTrace: () => void;
onStartSewing: () => void;
onResumeSewing: () => void;
onDeletePattern: () => void;
}
export function ProgressMonitor({
machineStatus,
patternInfo,
sewingProgress,
pesData,
onStartMaskTrace,
onStartSewing,
onResumeSewing,
onDeletePattern,
}: ProgressMonitorProps) {
// State indicators
const isSewing = machineStatus === MachineStatus.SEWING;
const isComplete = machineStatus === MachineStatus.SEWING_COMPLETE;
const isColorChange = machineStatus === MachineStatus.COLOR_CHANGE_WAIT;
const isMaskTracing = machineStatus === MachineStatus.MASK_TRACING;
const isMaskTraceComplete = machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
const isMaskTraceWait = machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT;
const stateVisual = getStateVisualInfo(machineStatus);
const progressPercent = patternInfo
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
: 0;
// Calculate color block information from pesData
const colorBlocks = pesData ? (() => {
const blocks: Array<{
colorIndex: number;
threadHex: string;
startStitch: number;
endStitch: number;
stitchCount: number;
}> = [];
let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
let blockStartStitch = 0;
for (let i = 0; i < pesData.stitches.length; i++) {
const stitchColorIndex = pesData.stitches[i][3];
// When color changes, save the previous block
if (stitchColorIndex !== currentColorIndex || i === pesData.stitches.length - 1) {
const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
blocks.push({
colorIndex: currentColorIndex,
threadHex: pesData.threads[currentColorIndex]?.hex || '#000000',
startStitch: blockStartStitch,
endStitch: endStitch,
stitchCount: endStitch - blockStartStitch,
});
currentColorIndex = stitchColorIndex;
blockStartStitch = i;
}
}
return blocks;
})() : [];
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;
const currentBlockIndex = colorBlocks.findIndex(
block => currentStitch >= block.startStitch && currentStitch < block.endStitch
);
return (
<div className="progress-panel">
<h2>Sewing Progress</h2>
{patternInfo && (
<div className="pattern-stats">
<div className="detail-row">
<span className="label">Total Stitches:</span>
<span className="value">{patternInfo.totalStitches}</span>
</div>
<div className="detail-row">
<span className="label">Estimated Time:</span>
<span className="value">
{Math.floor(patternInfo.totalTime / 60)}:
{(patternInfo.totalTime % 60).toString().padStart(2, '0')}
</span>
</div>
<div className="detail-row">
<span className="label">Speed:</span>
<span className="value">{patternInfo.speed} spm</span>
</div>
<div className="detail-row">
<span className="label">Bounds:</span>
<span className="value">
({patternInfo.boundLeft}, {patternInfo.boundTop}) to (
{patternInfo.boundRight}, {patternInfo.boundBottom})
</span>
</div>
</div>
)}
{colorBlocks.length > 0 && (
<div className="color-blocks">
<h3>Color Blocks</h3>
<div className="color-block-list">
{colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch;
const isCurrent = index === currentBlockIndex;
const isPending = currentStitch < block.startStitch;
// Calculate progress within current block
let blockProgress = 0;
if (isCurrent) {
blockProgress = ((currentStitch - block.startStitch) / block.stitchCount) * 100;
} else if (isCompleted) {
blockProgress = 100;
}
return (
<div
key={index}
className={`color-block-item ${
isCompleted ? 'completed' : isCurrent ? 'current' : 'pending'
}`}
>
<div className="block-header">
<div
className="color-swatch"
style={{ backgroundColor: block.threadHex }}
title={block.threadHex}
/>
<span className="block-label">
Thread {block.colorIndex + 1}
</span>
<span className="block-status">
{isCompleted ? '✓' : isCurrent ? '→' : '○'}
</span>
<span className="block-stitches">
{block.stitchCount} stitches
</span>
</div>
{isCurrent && (
<div className="block-progress-bar">
<div
className="block-progress-fill"
style={{ width: `${blockProgress}%` }}
/>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{sewingProgress && (
<div className="sewing-stats">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progressPercent}%` }} />
</div>
<div className="detail-row">
<span className="label">Current Stitch:</span>
<span className="value">
{sewingProgress.currentStitch} / {patternInfo?.totalStitches || 0}
</span>
</div>
<div className="detail-row">
<span className="label">Elapsed Time:</span>
<span className="value">
{Math.floor(sewingProgress.currentTime / 60)}:
{(sewingProgress.currentTime % 60).toString().padStart(2, '0')}
</span>
</div>
<div className="detail-row">
<span className="label">Position:</span>
<span className="value">
({(sewingProgress.positionX / 10).toFixed(1)}mm,{' '}
{(sewingProgress.positionY / 10).toFixed(1)}mm)
</span>
</div>
<div className="detail-row">
<span className="label">Progress:</span>
<span className="value">{progressPercent.toFixed(1)}%</span>
</div>
</div>
)}
{/* State Visual Indicator */}
{patternInfo && (
<div className={`state-indicator state-indicator-${stateVisual.color}`}>
<span className="state-icon">{stateVisual.icon}</span>
<div className="state-info">
<div className="state-label">{stateVisual.label}</div>
<div className="state-description">{stateVisual.description}</div>
</div>
</div>
)}
<div className="progress-actions">
{/* Mask trace waiting for confirmation */}
{isMaskTraceWait && (
<div className="status-message warning">
Press button on machine to start mask trace
</div>
)}
{/* Mask trace in progress */}
{isMaskTracing && (
<div className="status-message info">
Mask trace in progress...
</div>
)}
{/* Mask trace complete - waiting for confirmation */}
{isMaskTraceComplete && (
<>
<div className="status-message success">
Mask trace complete!
</div>
<div className="status-message warning">
Press button on machine to confirm (or trace again)
</div>
{canStartMaskTrace(machineStatus) && (
<button onClick={onStartMaskTrace} className="btn-secondary">
Trace Again
</button>
)}
</>
)}
{/* Ready to start (pattern uploaded) */}
{machineStatus === MachineStatus.SEWING_WAIT && (
<>
{canStartMaskTrace(machineStatus) && (
<button onClick={onStartMaskTrace} className="btn-secondary">
Start Mask Trace
</button>
)}
{canStartSewing(machineStatus) && (
<button onClick={onStartSewing} className="btn-primary">
Start Sewing
</button>
)}
</>
)}
{/* Resume sewing for interrupted states */}
{canResumeSewing(machineStatus) && (
<button onClick={onResumeSewing} className="btn-primary">
Resume Sewing
</button>
)}
{/* Color change needed */}
{isColorChange && (
<div className="status-message warning">
Waiting for color change - change thread and press button on machine
</div>
)}
{/* Sewing in progress */}
{isSewing && (
<div className="status-message info">
Sewing in progress...
</div>
)}
{/* Sewing complete */}
{isComplete && (
<div className="status-message success">
Sewing complete!
</div>
)}
{/* Delete pattern button - ONLY show when safe */}
{patternInfo && canDeletePattern(machineStatus) && (
<button onClick={onDeletePattern} className="btn-danger">
Delete Pattern
</button>
)}
{/* Show warning when delete is unavailable */}
{patternInfo && !canDeletePattern(machineStatus) && (
<div className="status-message info">
Pattern cannot be deleted during active operations
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,355 @@
import { useState, useCallback, useEffect } from "react";
import { BrotherPP1Service } from "../services/BrotherPP1Service";
import type {
MachineInfo,
PatternInfo,
SewingProgress,
} from "../types/machine";
import { MachineStatus, MachineStatusNames } from "../types/machine";
import {
PatternCacheService,
uuidToString,
} from "../services/PatternCacheService";
import type { PesPatternData } from "../utils/pystitchConverter";
export function useBrotherMachine() {
const [service] = useState(() => new BrotherPP1Service());
const [isConnected, setIsConnected] = useState(false);
const [machineInfo, setMachineInfo] = useState<MachineInfo | null>(null);
const [machineStatus, setMachineStatus] = useState<MachineStatus>(
MachineStatus.None,
);
const [machineError, setMachineError] = useState<number>(0);
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 [isPolling, setIsPolling] = useState(false);
const [resumeAvailable, setResumeAvailable] = useState(false);
const [resumeFileName, setResumeFileName] = useState<string | null>(null);
const [resumedPattern, setResumedPattern] = useState<PesPatternData | null>(
null,
);
// 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 = PatternCacheService.getPatternByUUID(uuidStr);
if (cached) {
console.log("[Resume] Pattern found in cache:", cached.fileName);
console.log("[Resume] Auto-loading cached pattern...");
setResumeAvailable(true);
setResumeFileName(cached.fileName);
setResumedPattern(cached.pesData);
// 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]);
const connect = useCallback(async () => {
try {
setError(null);
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);
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);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to disconnect");
}
}, [service]);
const refreshStatus = useCallback(async () => {
if (!isConnected) return;
try {
setIsPolling(true);
const state = await service.getMachineState();
setMachineStatus(state.status);
setMachineError(state.error);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to get status");
} finally {
setIsPolling(false);
}
}, [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 loadCachedPattern =
useCallback(async (): Promise<PesPatternData | null> => {
if (!resumeAvailable) return null;
try {
const machineUuid = await service.getPatternUUID();
if (!machineUuid) return null;
const uuidStr = uuidToString(machineUuid);
const cached = PatternCacheService.getPatternByUUID(uuidStr);
if (cached) {
console.log("[Resume] Loading cached pattern:", cached.fileName);
// Refresh pattern info from machine
await refreshPatternInfo();
return cached.pesData;
}
return null;
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load cached pattern",
);
return null;
}
}, [service, resumeAvailable, refreshPatternInfo]);
const uploadPattern = useCallback(
async (penData: Uint8Array, pesData: PesPatternData, fileName: string) => {
if (!isConnected) {
setError("Not connected to machine");
return;
}
try {
setError(null);
setUploadProgress(0);
const uuid = await service.uploadPattern(penData, (progress) => {
setUploadProgress(progress);
});
setUploadProgress(100);
// Cache the pattern with its UUID
const uuidStr = uuidToString(uuid);
PatternCacheService.savePattern(uuidStr, pesData, fileName);
console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr);
// Clear resume state since we just uploaded
setResumeAvailable(false);
setResumeFileName(null);
// Refresh status and pattern info after upload
await refreshStatus();
await refreshPatternInfo();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to upload pattern",
);
}
},
[service, isConnected, refreshStatus, refreshPatternInfo],
);
const startMaskTrace = useCallback(async () => {
if (!isConnected) return;
try {
setError(null);
await service.startMaskTrace();
await refreshStatus();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to start mask trace",
);
}
}, [service, isConnected, refreshStatus]);
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);
await service.deletePattern();
setPatternInfo(null);
setSewingProgress(null);
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete pattern");
}
}, [service, 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();
// Also refresh progress during sewing
if (machineStatus === MachineStatus.SEWING) {
await refreshProgress();
}
}, pollInterval);
return () => clearInterval(interval);
}, [isConnected, machineStatus, refreshStatus, refreshProgress]);
// 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,
isPolling,
resumeAvailable,
resumeFileName,
resumedPattern,
connect,
disconnect,
refreshStatus,
refreshPatternInfo,
uploadPattern,
startMaskTrace,
startSewing,
resumeSewing,
deletePattern,
checkResume,
loadCachedPattern,
};
}

13
src/index.css Normal file
View file

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,452 @@
import type {
MachineInfo,
PatternInfo,
SewingProgress,
} from "../types/machine";
import { MachineStatus } from "../types/machine";
// BLE Service and Characteristic UUIDs
const SERVICE_UUID = "a76eb9e0-f3ac-4990-84cf-3a94d2426b2b";
const WRITE_CHAR_UUID = "a76eb9e2-f3ac-4990-84cf-3a94d2426b2b";
const READ_CHAR_UUID = "a76eb9e1-f3ac-4990-84cf-3a94d2426b2b";
// Command IDs (big-endian)
const Commands = {
MACHINE_INFO: 0x0000,
MACHINE_STATE: 0x0001,
SERVICE_COUNT: 0x0100,
PATTERN_UUID_REQUEST: 0x0702,
MASK_TRACE: 0x0704,
LAYOUT_SEND: 0x0705,
EMB_SEWING_INFO_REQUEST: 0x0706,
PATTERN_SEWING_INFO: 0x0707,
EMB_SEWING_DATA_DELETE: 0x0708,
NEEDLE_MODE_INSTRUCTIONS: 0x0709,
EMB_UUID_SEND: 0x070a,
RESUME_FLAG_REQUEST: 0x070b,
RESUME: 0x070c,
START_SEWING: 0x070e,
MASK_TRACE_1: 0x0710,
EMB_ORG_POINT: 0x0800,
MACHINE_SETTING_INFO: 0x0c02,
SEND_DATA_INFO: 0x1200,
SEND_DATA: 0x1201,
CLEAR_ERROR: 0x1300,
};
export class BrotherPP1Service {
private device: BluetoothDevice | null = null;
private server: BluetoothRemoteGATTServer | null = null;
private writeCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
private readCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
private commandQueue: Array<() => Promise<void>> = [];
private isProcessingQueue = false;
async connect(): Promise<void> {
this.device = await navigator.bluetooth.requestDevice({
filters: [{ services: [SERVICE_UUID] }],
});
if (!this.device.gatt) {
throw new Error("GATT not available");
}
console.log("Connecting");
this.server = await this.device.gatt.connect();
console.log("Connected");
const service = await this.server.getPrimaryService(SERVICE_UUID);
console.log("Got primary service");
this.writeCharacteristic = await service.getCharacteristic(WRITE_CHAR_UUID);
this.readCharacteristic = await service.getCharacteristic(READ_CHAR_UUID);
console.log("Connected to Brother PP1 machine");
console.log("Send dummy command");
try {
await this.getMachineInfo();
console.log("Dummy command success");
} catch (e) {
console.log(e);
}
}
async disconnect(): Promise<void> {
// Clear any pending commands
this.commandQueue = [];
this.isProcessingQueue = false;
if (this.server) {
this.server.disconnect();
}
this.device = null;
this.server = null;
this.writeCharacteristic = null;
this.readCharacteristic = null;
}
isConnected(): boolean {
return this.server?.connected ?? false;
}
/**
* Process the command queue sequentially
*/
private async processQueue(): Promise<void> {
if (this.isProcessingQueue || this.commandQueue.length === 0) {
return;
}
this.isProcessingQueue = true;
while (this.commandQueue.length > 0) {
const command = this.commandQueue.shift();
if (command) {
try {
await command();
} catch (err) {
console.error("Command queue error:", err);
// Continue processing queue even if one command fails
}
}
}
this.isProcessingQueue = false;
}
/**
* Enqueue a Bluetooth command to be executed sequentially
*/
private async enqueueCommand<T>(operation: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.commandQueue.push(async () => {
try {
const result = await operation();
resolve(result);
} catch (err) {
reject(err);
}
});
// Start processing the queue
this.processQueue();
});
}
private async sendCommand(
cmdId: number,
data: Uint8Array = new Uint8Array(),
): Promise<Uint8Array> {
// Enqueue the command to ensure sequential execution
return this.enqueueCommand(async () => {
if (!this.writeCharacteristic || !this.readCharacteristic) {
throw new Error("Not connected");
}
// Build command with big-endian command ID
const command = new Uint8Array(2 + data.length);
command[0] = (cmdId >> 8) & 0xff; // High byte
command[1] = cmdId & 0xff; // Low byte
command.set(data, 2);
console.log(
"Sending command:",
Array.from(command)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" "),
);
console.log("Sending command");
// Write command and immediately read response
await this.writeCharacteristic.writeValueWithoutResponse(command);
console.log("delay");
// Small delay to ensure response is ready
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("reading response");
const responseData = await this.readCharacteristic.readValue();
const response = new Uint8Array(responseData.buffer);
console.log(
"Received response:",
Array.from(response)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" "),
);
return response;
});
}
async getMachineInfo(): Promise<MachineInfo> {
const response = await this.sendCommand(Commands.MACHINE_INFO);
// Skip 2-byte command header
const data = response.slice(2);
const decoder = new TextDecoder("ascii");
const serialNumber = decoder.decode(data.slice(2, 11)).replace(/\0/g, "");
const modelCode = decoder.decode(data.slice(39, 50)).replace(/\0/g, "");
// Software version (big-endian int16)
const swVersion = (data[0] << 8) | data[1];
// BT version (big-endian int16)
const btVersion = (data[24] << 8) | data[25];
// Max dimensions (little-endian int16)
const maxWidth = data[29] | (data[30] << 8);
const maxHeight = data[31] | (data[32] << 8);
// MAC address
const macAddress = Array.from(data.slice(16, 22))
.map((b) => b.toString(16).padStart(2, "0"))
.join(":")
.toUpperCase();
return {
serialNumber,
modelNumber: modelCode,
softwareVersion: `${(swVersion / 100).toFixed(2)}.${data[35]}`,
bluetoothVersion: btVersion,
maxWidth,
maxHeight,
macAddress,
};
}
async getMachineState(): Promise<{ status: MachineStatus; error: number }> {
const response = await this.sendCommand(Commands.MACHINE_STATE);
return {
status: response[2] as MachineStatus,
error: response[4],
};
}
async getPatternInfo(): Promise<PatternInfo> {
const response = await this.sendCommand(Commands.EMB_SEWING_INFO_REQUEST);
const data = response.slice(2);
const readInt16LE = (offset: number) =>
data[offset] | (data[offset + 1] << 8);
const readUInt16LE = (offset: number) =>
data[offset] | (data[offset + 1] << 8);
return {
boundLeft: readInt16LE(0),
boundTop: readInt16LE(2),
boundRight: readInt16LE(4),
boundBottom: readInt16LE(6),
totalTime: readUInt16LE(8),
totalStitches: readUInt16LE(10),
speed: readUInt16LE(12),
};
}
async getSewingProgress(): Promise<SewingProgress> {
const response = await this.sendCommand(Commands.PATTERN_SEWING_INFO);
const data = response.slice(2);
const readInt16LE = (offset: number) => {
const value = data[offset] | (data[offset + 1] << 8);
// Convert to signed 16-bit integer
return value > 0x7fff ? value - 0x10000 : value;
};
const readUInt16LE = (offset: number) =>
data[offset] | (data[offset + 1] << 8);
return {
currentStitch: readUInt16LE(0),
currentTime: readInt16LE(2),
stopTime: readInt16LE(4),
positionX: readInt16LE(6),
positionY: readInt16LE(8),
};
}
async deletePattern(): Promise<void> {
await this.sendCommand(Commands.EMB_SEWING_DATA_DELETE);
}
async sendDataInfo(length: number, checksum: number): Promise<void> {
const payload = new Uint8Array(7);
payload[0] = 0x03; // Type
// Length (little-endian uint32)
payload[1] = length & 0xff;
payload[2] = (length >> 8) & 0xff;
payload[3] = (length >> 16) & 0xff;
payload[4] = (length >> 24) & 0xff;
// Checksum (little-endian uint16)
payload[5] = checksum & 0xff;
payload[6] = (checksum >> 8) & 0xff;
const response = await this.sendCommand(Commands.SEND_DATA_INFO, payload);
if (response[2] !== 0x00) {
throw new Error("Data info rejected");
}
}
async sendDataChunk(offset: number, data: Uint8Array): Promise<boolean> {
const checksum = data.reduce((sum, byte) => (sum + byte) & 0xff, 0);
const payload = new Uint8Array(4 + data.length + 1);
// Offset (little-endian uint32)
payload[0] = offset & 0xff;
payload[1] = (offset >> 8) & 0xff;
payload[2] = (offset >> 16) & 0xff;
payload[3] = (offset >> 24) & 0xff;
payload.set(data, 4);
payload[4 + data.length] = checksum;
const response = await this.sendCommand(Commands.SEND_DATA, payload);
// 0x00 = complete, 0x02 = continue
return response[2] === 0x00;
}
async sendUUID(uuid: Uint8Array): Promise<void> {
const response = await this.sendCommand(Commands.EMB_UUID_SEND, uuid);
if (response[2] !== 0x00) {
throw new Error("UUID rejected");
}
}
async sendLayout(
moveX: number,
moveY: number,
sizeX: number,
sizeY: number,
rotate: number,
flip: number,
frame: number,
): Promise<void> {
const payload = new Uint8Array(12);
const writeInt16LE = (offset: number, value: number) => {
payload[offset] = value & 0xff;
payload[offset + 1] = (value >> 8) & 0xff;
};
writeInt16LE(0, moveX);
writeInt16LE(2, moveY);
writeInt16LE(4, sizeX);
writeInt16LE(6, sizeY);
writeInt16LE(8, rotate);
payload[10] = flip;
payload[11] = frame;
await this.sendCommand(Commands.LAYOUT_SEND, payload);
}
async startMaskTrace(): Promise<void> {
const payload = new Uint8Array([0x01]);
await this.sendCommand(Commands.MASK_TRACE, payload);
}
async startSewing(): Promise<void> {
await this.sendCommand(Commands.START_SEWING);
}
async resumeSewing(): Promise<void> {
// Resume uses the same START_SEWING command as initial start
// The machine tracks current position and resumes from there
await this.sendCommand(Commands.START_SEWING);
}
async uploadPattern(
data: Uint8Array,
onProgress?: (progress: number) => void,
): Promise<Uint8Array> {
// Calculate checksum
const checksum = data.reduce((sum, byte) => sum + byte, 0) & 0xffff;
// Delete existing pattern
await this.deletePattern();
// Send data info
await this.sendDataInfo(data.length, checksum);
// Send data in chunks (max chunk size ~500 bytes to be safe with BLE MTU)
const chunkSize = 500;
let offset = 0;
while (offset < data.length) {
const chunk = data.slice(
offset,
Math.min(offset + chunkSize, data.length),
);
const isComplete = await this.sendDataChunk(offset, chunk);
offset += chunk.length;
if (onProgress) {
onProgress((offset / data.length) * 100);
}
if (isComplete) {
break;
}
// Small delay between chunks
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Generate random UUID
const uuid = crypto.getRandomValues(new Uint8Array(16));
await this.sendUUID(uuid);
// Send default layout (no transformation)
await this.sendLayout(0, 0, 0, 0, 0, 0, 0);
console.log(
"Pattern uploaded successfully with UUID:",
Array.from(uuid)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
// Return UUID for caching
return uuid;
}
/**
* Request the UUID of the pattern currently loaded on the machine
*/
async getPatternUUID(): Promise<Uint8Array | null> {
try {
const response = await this.sendCommand(Commands.PATTERN_UUID_REQUEST);
// Response format: [cmd_high, cmd_low, uuid_bytes...]
// UUID starts at index 2 (16 bytes)
if (response.length < 18) {
// Not enough data for UUID
console.log(
"[BrotherPP1] Response too short for UUID:",
response.length,
);
return null;
}
// Extract UUID (16 bytes starting at index 2)
const uuid = response.slice(2, 18);
// Check if UUID is all zeros (no pattern loaded)
const allZeros = uuid.every((byte) => byte === 0);
if (allZeros) {
console.log("[BrotherPP1] UUID is all zeros, no pattern loaded");
return null;
}
return uuid;
} catch (err) {
console.error("[BrotherPP1] Failed to get pattern UUID:", err);
return null;
}
}
}

View file

@ -0,0 +1,155 @@
import type { PesPatternData } from '../utils/pystitchConverter';
interface CachedPattern {
uuid: string;
pesData: PesPatternData;
fileName: string;
timestamp: number;
}
const CACHE_KEY = 'brother_pattern_cache';
/**
* Convert UUID Uint8Array to hex string
*/
export function uuidToString(uuid: Uint8Array): string {
return Array.from(uuid).map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Convert hex string to UUID Uint8Array
*/
export function stringToUuid(str: string): Uint8Array {
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(str.substr(i * 2, 2), 16);
}
return bytes;
}
export class PatternCacheService {
/**
* Save pattern to local storage with its UUID
*/
static savePattern(
uuid: string,
pesData: PesPatternData,
fileName: string
): void {
try {
// Convert penData Uint8Array to array for JSON serialization
const pesDataWithArrayPenData = {
...pesData,
penData: Array.from(pesData.penData) as any,
};
const cached: CachedPattern = {
uuid,
pesData: pesDataWithArrayPenData,
fileName,
timestamp: Date.now(),
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid);
} catch (err) {
console.error('[PatternCache] Failed to save pattern:', err);
// If quota exceeded, clear and try again
if (err instanceof Error && err.name === 'QuotaExceededError') {
this.clearCache();
}
}
}
/**
* Get cached pattern by UUID
*/
static getPatternByUUID(uuid: string): CachedPattern | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) {
return null;
}
const pattern: CachedPattern = JSON.parse(cached);
// Check if UUID matches
if (pattern.uuid !== uuid) {
console.log('[PatternCache] UUID mismatch. Cached:', pattern.uuid, 'Requested:', uuid);
return null;
}
// Restore Uint8Array from array inside pesData
if (Array.isArray(pattern.pesData.penData)) {
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
}
console.log('[PatternCache] Found cached pattern:', pattern.fileName, 'UUID:', uuid);
return pattern;
} catch (err) {
console.error('[PatternCache] Failed to retrieve pattern:', err);
return null;
}
}
/**
* Get the most recent cached pattern (regardless of UUID)
*/
static getMostRecentPattern(): CachedPattern | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) {
return null;
}
const pattern: CachedPattern = JSON.parse(cached);
// Restore Uint8Array from array inside pesData
if (Array.isArray(pattern.pesData.penData)) {
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
}
return pattern;
} catch (err) {
console.error('[PatternCache] Failed to retrieve pattern:', err);
return null;
}
}
/**
* Check if a pattern with the given UUID exists in cache
*/
static hasPattern(uuid: string): boolean {
const pattern = this.getMostRecentPattern();
return pattern?.uuid === uuid;
}
/**
* Clear the pattern cache
*/
static clearCache(): void {
try {
localStorage.removeItem(CACHE_KEY);
console.log('[PatternCache] Cache cleared');
} catch (err) {
console.error('[PatternCache] Failed to clear cache:', err);
}
}
/**
* Get cache info for debugging
*/
static getCacheInfo(): { hasCache: boolean; fileName?: string; uuid?: string; age?: number } {
const pattern = this.getMostRecentPattern();
if (!pattern) {
return { hasCache: false };
}
return {
hasCache: true,
fileName: pattern.fileName,
uuid: pattern.uuid,
age: Date.now() - pattern.timestamp,
};
}
}

103
src/types/machine.ts Normal file
View file

@ -0,0 +1,103 @@
// Brother PP1 Machine Types
export const MachineStatus = {
Initial: 0x00,
LowerThread: 0x01,
IDLE: 0x10,
SEWING_WAIT: 0x11,
SEWING_DATA_RECEIVE: 0x12,
MASK_TRACE_LOCK_WAIT: 0x20,
MASK_TRACING: 0x21,
MASK_TRACE_COMPLETE: 0x22,
SEWING: 0x30,
SEWING_COMPLETE: 0x31,
SEWING_INTERRUPTION: 0x32,
COLOR_CHANGE_WAIT: 0x40,
PAUSE: 0x41,
STOP: 0x42,
HOOP_AVOIDANCE: 0x50,
HOOP_AVOIDANCEING: 0x51,
RL_RECEIVING: 0x60,
RL_RECEIVED: 0x61,
None: 0xDD,
TryConnecting: 0xFF,
} as const;
export type MachineStatus = typeof MachineStatus[keyof typeof MachineStatus];
export const MachineStatusNames: Record<MachineStatus, string> = {
[MachineStatus.Initial]: 'Initial',
[MachineStatus.LowerThread]: 'Lower Thread',
[MachineStatus.IDLE]: 'Idle',
[MachineStatus.SEWING_WAIT]: 'Ready to Sew',
[MachineStatus.SEWING_DATA_RECEIVE]: 'Receiving Data',
[MachineStatus.MASK_TRACE_LOCK_WAIT]: 'Waiting for Mask Trace',
[MachineStatus.MASK_TRACING]: 'Mask Tracing',
[MachineStatus.MASK_TRACE_COMPLETE]: 'Mask Trace Complete',
[MachineStatus.SEWING]: 'Sewing',
[MachineStatus.SEWING_COMPLETE]: 'Complete',
[MachineStatus.SEWING_INTERRUPTION]: 'Interrupted',
[MachineStatus.COLOR_CHANGE_WAIT]: 'Waiting for Color Change',
[MachineStatus.PAUSE]: 'Paused',
[MachineStatus.STOP]: 'Stopped',
[MachineStatus.HOOP_AVOIDANCE]: 'Hoop Avoidance',
[MachineStatus.HOOP_AVOIDANCEING]: 'Hoop Avoidance In Progress',
[MachineStatus.RL_RECEIVING]: 'RL Receiving',
[MachineStatus.RL_RECEIVED]: 'RL Received',
[MachineStatus.None]: 'None',
[MachineStatus.TryConnecting]: 'Connecting',
};
export interface MachineInfo {
serialNumber: string;
modelNumber: string;
softwareVersion: string;
bluetoothVersion: number;
maxWidth: number; // in 0.1mm units
maxHeight: number; // in 0.1mm units
macAddress: string;
}
export interface PatternInfo {
totalStitches: number;
totalTime: number; // seconds
speed: number; // stitches per minute
boundLeft: number;
boundTop: number;
boundRight: number;
boundBottom: number;
}
export interface SewingProgress {
currentStitch: number;
currentTime: number; // seconds
stopTime: number;
positionX: number; // in 0.1mm units
positionY: number; // in 0.1mm units
}
export interface PenStitch {
x: number;
y: number;
flags: number;
isJump: boolean;
}
export interface PenColorBlock {
startStitch: number;
endStitch: number;
colorIndex: number;
}
export interface PenData {
stitches: PenStitch[];
colorBlocks: PenColorBlock[];
totalStitches: number;
colorCount: number;
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
}

View file

@ -0,0 +1,100 @@
/**
* Brother PP1 Protocol Error Codes
* Based on App/Asura.Core/Models/SewingMachineError.cs
*/
export enum SewingMachineError {
NeedlePositionError = 0x00,
SafetyError = 0x01,
LowerThreadSafetyError = 0x02,
LowerThreadFreeError = 0x03,
RestartError10 = 0x10,
RestartError11 = 0x11,
RestartError12 = 0x12,
RestartError13 = 0x13,
RestartError14 = 0x14,
RestartError15 = 0x15,
RestartError16 = 0x16,
RestartError17 = 0x17,
RestartError18 = 0x18,
RestartError19 = 0x19,
RestartError1A = 0x1A,
RestartError1B = 0x1B,
RestartError1C = 0x1C,
NeedlePlateError = 0x20,
ThreadLeverError = 0x21,
UpperThreadError = 0x60,
LowerThreadError = 0x61,
UpperThreadSewingStartError = 0x62,
PRWiperError = 0x63,
HoopError = 0x70,
NoHoopError = 0x71,
InitialHoopError = 0x72,
RegularInspectionError = 0x80,
Setting = 0x98,
None = 0xDD,
Unknown = 0xEE,
OtherError = 0xFF,
}
/**
* Human-readable error messages
*/
const ERROR_MESSAGES: Record<number, string> = {
[SewingMachineError.NeedlePositionError]: 'Needle Position Error',
[SewingMachineError.SafetyError]: 'Safety Error',
[SewingMachineError.LowerThreadSafetyError]: 'Lower Thread Safety Error',
[SewingMachineError.LowerThreadFreeError]: 'Lower Thread Free Error',
[SewingMachineError.RestartError10]: 'Restart Required (0x10)',
[SewingMachineError.RestartError11]: 'Restart Required (0x11)',
[SewingMachineError.RestartError12]: 'Restart Required (0x12)',
[SewingMachineError.RestartError13]: 'Restart Required (0x13)',
[SewingMachineError.RestartError14]: 'Restart Required (0x14)',
[SewingMachineError.RestartError15]: 'Restart Required (0x15)',
[SewingMachineError.RestartError16]: 'Restart Required (0x16)',
[SewingMachineError.RestartError17]: 'Restart Required (0x17)',
[SewingMachineError.RestartError18]: 'Restart Required (0x18)',
[SewingMachineError.RestartError19]: 'Restart Required (0x19)',
[SewingMachineError.RestartError1A]: 'Restart Required (0x1A)',
[SewingMachineError.RestartError1B]: 'Restart Required (0x1B)',
[SewingMachineError.RestartError1C]: 'Restart Required (0x1C)',
[SewingMachineError.NeedlePlateError]: 'Needle Plate Error',
[SewingMachineError.ThreadLeverError]: 'Thread Lever Error',
[SewingMachineError.UpperThreadError]: 'Upper Thread Error',
[SewingMachineError.LowerThreadError]: 'Lower Thread Error',
[SewingMachineError.UpperThreadSewingStartError]: 'Upper Thread Error at Sewing Start',
[SewingMachineError.PRWiperError]: 'PR Wiper Error',
[SewingMachineError.HoopError]: 'Hoop Error',
[SewingMachineError.NoHoopError]: 'No Hoop Detected',
[SewingMachineError.InitialHoopError]: 'Initial Hoop Error',
[SewingMachineError.RegularInspectionError]: 'Regular Inspection Required',
[SewingMachineError.Setting]: 'Settings Error',
[SewingMachineError.Unknown]: 'Unknown Error',
[SewingMachineError.OtherError]: 'Other Error',
};
/**
* Get human-readable error message for an error code
*/
export function getErrorMessage(errorCode: number): string | null {
// 0xDD (221) is the default "no error" value
if (errorCode === SewingMachineError.None) {
return null; // No error to display
}
// Look up known error message
const message = ERROR_MESSAGES[errorCode];
if (message) {
return message;
}
// Unknown error code
return `Machine Error ${errorCode} (0x${errorCode.toString(16).toUpperCase().padStart(2, '0')})`;
}
/**
* Check if error code represents an actual error condition
*/
export function hasError(errorCode: number): boolean {
return errorCode !== SewingMachineError.None;
}

View file

@ -0,0 +1,184 @@
import { MachineStatus } from '../types/machine';
/**
* Machine state categories for safety logic
*/
export const MachineStateCategory = {
IDLE: 'idle',
ACTIVE: 'active',
WAITING: 'waiting',
COMPLETE: 'complete',
INTERRUPTED: 'interrupted',
ERROR: 'error',
} as const;
export type MachineStateCategoryType = typeof MachineStateCategory[keyof typeof MachineStateCategory];
/**
* Categorize a machine status into a semantic safety category
*/
export function getMachineStateCategory(status: MachineStatus): MachineStateCategoryType {
switch (status) {
// IDLE states - safe to perform any action
case MachineStatus.IDLE:
case MachineStatus.SEWING_WAIT:
case MachineStatus.Initial:
case MachineStatus.LowerThread:
return MachineStateCategory.IDLE;
// ACTIVE states - operation in progress, dangerous to interrupt
case MachineStatus.SEWING:
case MachineStatus.MASK_TRACING:
case MachineStatus.SEWING_DATA_RECEIVE:
case MachineStatus.HOOP_AVOIDANCEING:
return MachineStateCategory.ACTIVE;
// WAITING states - waiting for user/machine action
case MachineStatus.COLOR_CHANGE_WAIT:
case MachineStatus.MASK_TRACE_LOCK_WAIT:
case MachineStatus.HOOP_AVOIDANCE:
return MachineStateCategory.WAITING;
// COMPLETE states - operation finished
case MachineStatus.SEWING_COMPLETE:
case MachineStatus.MASK_TRACE_COMPLETE:
case MachineStatus.RL_RECEIVED:
return MachineStateCategory.COMPLETE;
// INTERRUPTED states - operation paused/stopped
case MachineStatus.PAUSE:
case MachineStatus.STOP:
case MachineStatus.SEWING_INTERRUPTION:
return MachineStateCategory.INTERRUPTED;
// ERROR/UNKNOWN states
case MachineStatus.None:
case MachineStatus.TryConnecting:
case MachineStatus.RL_RECEIVING:
default:
return MachineStateCategory.ERROR;
}
}
/**
* Determines if the pattern can be safely deleted in the current state.
* Prevents deletion during active operations (SEWING, MASK_TRACING, etc.)
*/
export function canDeletePattern(status: MachineStatus): boolean {
const category = getMachineStateCategory(status);
// Can only delete in IDLE or COMPLETE states, never during ACTIVE operations
return category === MachineStateCategory.IDLE ||
category === MachineStateCategory.COMPLETE;
}
/**
* Determines if a pattern can be safely uploaded in the current state.
* Only allow uploads when machine is idle.
*/
export function canUploadPattern(status: MachineStatus): boolean {
const category = getMachineStateCategory(status);
// Can only upload in IDLE state
return category === MachineStateCategory.IDLE;
}
/**
* Determines if sewing can be started in the current state.
* Allows starting from ready state or resuming from interrupted states.
*/
export function canStartSewing(status: MachineStatus): boolean {
// Only in specific ready states
return status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.PAUSE ||
status === MachineStatus.STOP ||
status === MachineStatus.SEWING_INTERRUPTION;
}
/**
* Determines if mask trace can be started in the current state.
*/
export function canStartMaskTrace(status: MachineStatus): boolean {
// Only when ready or after previous trace
return status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.MASK_TRACE_COMPLETE;
}
/**
* Determines if sewing can be resumed in the current state.
* Only for interrupted operations (PAUSE, STOP, SEWING_INTERRUPTION).
*/
export function canResumeSewing(status: MachineStatus): boolean {
// Only in interrupted states
const category = getMachineStateCategory(status);
return category === MachineStateCategory.INTERRUPTED;
}
/**
* Determines if disconnect should show a confirmation dialog.
* Confirms if disconnecting during active operation or while waiting.
*/
export function shouldConfirmDisconnect(status: MachineStatus): boolean {
const category = getMachineStateCategory(status);
// Confirm if disconnecting during active operation or waiting for action
return category === MachineStateCategory.ACTIVE ||
category === MachineStateCategory.WAITING;
}
/**
* Visual information for a machine state
*/
export interface StateVisualInfo {
color: string;
icon: string;
label: string;
description: string;
}
/**
* Get visual styling information for a machine state.
* Returns color, icon, label, and description for UI display.
*/
export function getStateVisualInfo(status: MachineStatus): StateVisualInfo {
const category = getMachineStateCategory(status);
// Map state category to visual properties
const visualMap: Record<MachineStateCategoryType, StateVisualInfo> = {
[MachineStateCategory.IDLE]: {
color: 'info',
icon: '⭕',
label: 'Ready',
description: 'Machine is idle and ready for operations'
},
[MachineStateCategory.ACTIVE]: {
color: 'warning',
icon: '▶️',
label: 'Active',
description: 'Operation in progress - do not interrupt'
},
[MachineStateCategory.WAITING]: {
color: 'warning',
icon: '⏸️',
label: 'Waiting',
description: 'Waiting for user or machine action'
},
[MachineStateCategory.COMPLETE]: {
color: 'success',
icon: '✅',
label: 'Complete',
description: 'Operation completed successfully'
},
[MachineStateCategory.INTERRUPTED]: {
color: 'danger',
icon: '⏹️',
label: 'Interrupted',
description: 'Operation paused or stopped'
},
[MachineStateCategory.ERROR]: {
color: 'danger',
icon: '❌',
label: 'Error',
description: 'Machine in error or unknown state'
}
};
return visualMap[category];
}

128
src/utils/penParser.ts Normal file
View file

@ -0,0 +1,128 @@
import type { PenData, PenStitch, PenColorBlock } from '../types/machine';
// PEN format flags
const PEN_FEED_DATA = 0x01; // Y-coordinate low byte, bit 0
const PEN_COLOR_END = 0x03; // X-coordinate low byte, bits 0-2
const PEN_DATA_END = 0x05; // X-coordinate low byte, bits 0-2
export function parsePenData(data: Uint8Array): PenData {
if (data.length < 4 || data.length % 4 !== 0) {
throw new Error(`Invalid PEN data size: ${data.length} bytes`);
}
const stitches: PenStitch[] = [];
const colorBlocks: PenColorBlock[] = [];
const stitchCount = data.length / 4;
let currentColorStart = 0;
let currentColor = 0;
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
console.log(`Parsing PEN data: ${data.length} bytes, ${stitchCount} stitches`);
for (let i = 0; i < stitchCount; i++) {
const offset = i * 4;
// Extract coordinates (shifted left by 3 bits in PEN format)
const xRaw = data[offset] | (data[offset + 1] << 8);
const yRaw = data[offset + 2] | (data[offset + 3] << 8);
// Extract flags from low 3 bits
const xFlags = data[offset] & 0x07;
const yFlags = data[offset + 2] & 0x07;
// Decode coordinates (shift right by 3 to get actual position)
// Using signed 16-bit interpretation
let x = (xRaw >> 3);
let y = (yRaw >> 3);
// Convert to signed if needed
if (x > 0x7FF) x = x - 0x2000;
if (y > 0x7FF) y = y - 0x2000;
const stitch: PenStitch = {
x,
y,
flags: (xFlags & 0x07) | (yFlags & 0x07),
isJump: (yFlags & PEN_FEED_DATA) !== 0,
};
stitches.push(stitch);
// Track bounds
if (!stitch.isJump) {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
// Check for color change or data end
if (xFlags === PEN_COLOR_END) {
const block: PenColorBlock = {
startStitch: currentColorStart,
endStitch: i,
colorIndex: currentColor,
};
colorBlocks.push(block);
console.log(
`Color ${currentColor}: stitches ${currentColorStart}-${i} (${
i - currentColorStart + 1
} stitches)`
);
currentColor++;
currentColorStart = i + 1;
} else if (xFlags === PEN_DATA_END) {
if (currentColorStart < i) {
const block: PenColorBlock = {
startStitch: currentColorStart,
endStitch: i,
colorIndex: currentColor,
};
colorBlocks.push(block);
console.log(
`Color ${currentColor} (final): stitches ${currentColorStart}-${i} (${
i - currentColorStart + 1
} stitches)`
);
currentColor++;
}
console.log(`Data end marker at stitch ${i}`);
break;
}
}
const result: PenData = {
stitches,
colorBlocks,
totalStitches: stitches.length,
colorCount: colorBlocks.length,
bounds: {
minX: minX === Infinity ? 0 : minX,
maxX: maxX === -Infinity ? 0 : maxX,
minY: minY === Infinity ? 0 : minY,
maxY: maxY === -Infinity ? 0 : maxY,
},
};
console.log(
`Parsed: ${result.totalStitches} stitches, ${result.colorCount} colors`
);
console.log(`Bounds: (${result.bounds.minX}, ${result.bounds.minY}) to (${result.bounds.maxX}, ${result.bounds.maxY})`);
return result;
}
export function getStitchColor(penData: PenData, stitchIndex: number): number {
for (const block of penData.colorBlocks) {
if (stitchIndex >= block.startStitch && stitchIndex <= block.endStitch) {
return block.colorIndex;
}
}
return -1;
}

View file

@ -0,0 +1,90 @@
import { loadPyodide, type PyodideInterface } from "pyodide";
export type PyodideState = "not_loaded" | "loading" | "ready" | "error";
class PyodideLoader {
private pyodide: PyodideInterface | null = null;
private state: PyodideState = "not_loaded";
private error: string | null = null;
private loadPromise: Promise<PyodideInterface> | null = null;
/**
* Get the current Pyodide state
*/
getState(): PyodideState {
return this.state;
}
/**
* Get the error message if state is 'error'
*/
getError(): string | null {
return this.error;
}
/**
* Initialize Pyodide and install PyStitch
*/
async initialize(): Promise<PyodideInterface> {
// If already ready, return immediately
if (this.state === "ready" && this.pyodide) {
return this.pyodide;
}
// If currently loading, wait for the existing promise
if (this.loadPromise) {
return this.loadPromise;
}
// Start loading
this.state = "loading";
this.error = null;
this.loadPromise = (async () => {
try {
console.log("[PyodideLoader] Loading Pyodide...");
// Load Pyodide with CDN indexURL for packages
// The core files will be loaded from our bundle, but packages will come from CDN
this.pyodide = await loadPyodide();
console.log("[PyodideLoader] Pyodide loaded, loading micropip...");
// Load micropip package
/*await this.pyodide.loadPackage('micropip');
console.log('[PyodideLoader] Installing PyStitch...');
// Install PyStitch using micropip
await this.pyodide.runPythonAsync(`
import micropip
await micropip.install('pystitch')
`);*/
await this.pyodide.loadPackage("pystitch-1.0.0-py3-none-any.whl");
console.log("[PyodideLoader] PyStitch installed successfully");
this.state = "ready";
return this.pyodide;
} catch (err) {
this.state = "error";
this.error =
err instanceof Error ? err.message : "Unknown error loading Pyodide";
console.error("[PyodideLoader] Error:", this.error);
throw err;
}
})();
return this.loadPromise;
}
/**
* Get the Pyodide instance (must be initialized first)
*/
getInstance(): PyodideInterface | null {
return this.pyodide;
}
}
// Export singleton instance
export const pyodideLoader = new PyodideLoader();

View file

@ -0,0 +1,233 @@
import { pyodideLoader } from './pyodideLoader';
// PEN format flags
const PEN_FEED_DATA = 0x01; // Y-coordinate low byte, bit 0 (jump)
const PEN_COLOR_END = 0x03; // X-coordinate low byte, bits 0-2
const PEN_DATA_END = 0x05; // X-coordinate low byte, bits 0-2
// Embroidery command constants (from pyembroidery)
const MOVE = 0x10;
const COLOR_CHANGE = 0x40;
const STOP = 0x80;
const END = 0x100;
export interface PesPatternData {
stitches: number[][];
threads: Array<{
color: number;
hex: string;
}>;
penData: Uint8Array;
colorCount: number;
stitchCount: number;
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
}
/**
* Reads a PES file using PyStitch and converts it to PEN format
*/
export async function convertPesToPen(file: File): Promise<PesPatternData> {
// Ensure Pyodide is initialized
const pyodide = await pyodideLoader.initialize();
// Read the PES file
const buffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(buffer);
// Write file to Pyodide virtual filesystem
const filename = '/tmp/pattern.pes';
pyodide.FS.writeFile(filename, uint8Array);
// Read the pattern using PyStitch
const result = await pyodide.runPythonAsync(`
import pystitch
# Read the PES file
pattern = pystitch.read('${filename}')
# PyStitch groups stitches by color blocks using get_as_stitchblock
# This returns tuples of (thread, stitches_list) for each color block
stitches_with_colors = []
block_index = 0
# Iterate through stitch blocks
# Each block is a tuple containing (thread, stitch_list)
for block in pattern.get_as_stitchblock():
if isinstance(block, tuple):
# Extract thread and stitch list from tuple
thread_obj = None
stitches_list = None
for elem in block:
# Check if this is the thread object (has color or hex_color attributes)
if hasattr(elem, 'color') or hasattr(elem, 'hex_color'):
thread_obj = elem
# Check if this is the stitch list
elif isinstance(elem, list) and len(elem) > 0 and isinstance(elem[0], list):
stitches_list = elem
if stitches_list:
# Find the index of this thread in the threadlist
thread_index = block_index
if thread_obj and hasattr(pattern, 'threadlist'):
for i, t in enumerate(pattern.threadlist):
if t is thread_obj:
thread_index = i
break
for stitch in stitches_list:
# stitch is [x, y, command]
stitches_with_colors.append([stitch[0], stitch[1], stitch[2], thread_index])
block_index += 1
# Convert to JSON-serializable format
{
'stitches': stitches_with_colors,
'threads': [
{
'color': thread.color if hasattr(thread, 'color') else 0,
'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000'
}
for thread in pattern.threadlist
],
'thread_count': len(pattern.threadlist),
'stitch_count': len(stitches_with_colors),
'block_count': block_index
}
`);
// Convert Python result to JavaScript
const data = result.toJs({ dict_converter: Object.fromEntries });
// Clean up virtual file
try {
pyodide.FS.unlink(filename);
} catch (e) {
// Ignore errors
}
// Extract stitches and validate
const stitches: number[][] = Array.from(data.stitches).map((stitch: any) =>
Array.from(stitch) as number[]
);
if (!stitches || stitches.length === 0) {
throw new Error('Invalid PES file or no stitches found');
}
// Extract thread data
const threads = data.threads.map((thread: any) => ({
color: thread.color || 0,
hex: thread.hex || '#000000',
}));
// Track bounds
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
// Convert to PEN format
const penStitches: number[] = [];
let currentColor = stitches[0]?.[3] ?? 0; // Track current color using stitch color index
for (let i = 0; i < stitches.length; i++) {
const stitch = stitches[i];
const x = Math.round(stitch[0]);
const y = Math.round(stitch[1]);
const cmd = stitch[2];
const stitchColor = stitch[3]; // Color index from PyStitch
// Track bounds for non-jump stitches
if ((cmd & MOVE) === 0) {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
// Encode coordinates with flags in low 3 bits
// Shift coordinates left by 3 bits to make room for flags
let xEncoded = (x << 3) & 0xFFFF;
let yEncoded = (y << 3) & 0xFFFF;
// Add jump flag if this is a move command
if ((cmd & MOVE) !== 0) {
yEncoded |= PEN_FEED_DATA;
}
// Check for color change by comparing stitch color index
// Mark the LAST stitch of the previous color with PEN_COLOR_END
const nextStitch = stitches[i + 1];
const nextStitchColor = nextStitch?.[3];
if (nextStitchColor !== undefined && nextStitchColor !== stitchColor) {
// This is the last stitch before a color change
xEncoded = (xEncoded & 0xFFF8) | PEN_COLOR_END;
currentColor = nextStitchColor;
}
// Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
penStitches.push(
xEncoded & 0xFF,
(xEncoded >> 8) & 0xFF,
yEncoded & 0xFF,
(yEncoded >> 8) & 0xFF
);
// Check for end command
if ((cmd & END) !== 0) {
// Mark as data end
const lastIdx = penStitches.length - 4;
penStitches[lastIdx] = (penStitches[lastIdx] & 0xF8) | PEN_DATA_END;
break;
}
}
// Mark the last stitch with DATA_END if not already marked
if (penStitches.length > 0) {
const lastIdx = penStitches.length - 4;
if ((penStitches[lastIdx] & 0x07) !== PEN_DATA_END) {
penStitches[lastIdx] = (penStitches[lastIdx] & 0xF8) | PEN_DATA_END;
}
}
const penData = new Uint8Array(penStitches);
return {
stitches,
threads,
penData,
colorCount: data.thread_count,
stitchCount: data.stitch_count,
bounds: {
minX: minX === Infinity ? 0 : minX,
maxX: maxX === -Infinity ? 0 : maxX,
minY: minY === Infinity ? 0 : minY,
maxY: maxY === -Infinity ? 0 : maxY,
},
};
}
/**
* Get thread color from pattern data
*/
export function getThreadColor(data: PesPatternData, colorIndex: number): string {
if (!data.threads || colorIndex < 0 || colorIndex >= data.threads.length) {
// Default colors if not specified or index out of bounds
const defaultColors = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
];
const safeIndex = Math.max(0, colorIndex) % defaultColors.length;
return defaultColors[safeIndex];
}
return data.threads[colorIndex]?.hex || '#000000';
}

125
src/web-bluetooth.d.ts vendored Normal file
View file

@ -0,0 +1,125 @@
// WebBluetooth API type declarations
// https://webbluetoothcg.github.io/web-bluetooth/
interface BluetoothRemoteGATTServer {
device: BluetoothDevice;
connected: boolean;
connect(): Promise<BluetoothRemoteGATTServer>;
disconnect(): void;
getPrimaryService(service: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService>;
getPrimaryServices(service?: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService[]>;
}
interface BluetoothDevice extends EventTarget {
id: string;
name?: string;
gatt?: BluetoothRemoteGATTServer;
watchingAdvertisements: boolean;
watchAdvertisements(options?: WatchAdvertisementsOptions): Promise<void>;
unwatchAdvertisements(): void;
forget(): Promise<void>;
addEventListener(
type: 'gattserverdisconnected' | 'advertisementreceived',
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void;
}
interface WatchAdvertisementsOptions {
signal?: AbortSignal;
}
interface BluetoothRemoteGATTService extends EventTarget {
device: BluetoothDevice;
uuid: string;
isPrimary: boolean;
getCharacteristic(characteristic: BluetoothCharacteristicUUID): Promise<BluetoothRemoteGATTCharacteristic>;
getCharacteristics(characteristic?: BluetoothCharacteristicUUID): Promise<BluetoothRemoteGATTCharacteristic[]>;
getIncludedService(service: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService>;
getIncludedServices(service?: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService[]>;
}
interface BluetoothRemoteGATTCharacteristic extends EventTarget {
service: BluetoothRemoteGATTService;
uuid: string;
properties: BluetoothCharacteristicProperties;
value?: DataView;
getDescriptor(descriptor: BluetoothDescriptorUUID): Promise<BluetoothRemoteGATTDescriptor>;
getDescriptors(descriptor?: BluetoothDescriptorUUID): Promise<BluetoothRemoteGATTDescriptor[]>;
readValue(): Promise<DataView>;
writeValue(value: BufferSource): Promise<void>;
writeValueWithResponse(value: BufferSource): Promise<void>;
writeValueWithoutResponse(value: BufferSource): Promise<void>;
startNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
stopNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
addEventListener(
type: 'characteristicvaluechanged',
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void;
}
interface BluetoothCharacteristicProperties {
broadcast: boolean;
read: boolean;
writeWithoutResponse: boolean;
write: boolean;
notify: boolean;
indicate: boolean;
authenticatedSignedWrites: boolean;
reliableWrite: boolean;
writableAuxiliaries: boolean;
}
interface BluetoothRemoteGATTDescriptor {
characteristic: BluetoothRemoteGATTCharacteristic;
uuid: string;
value?: DataView;
readValue(): Promise<DataView>;
writeValue(value: BufferSource): Promise<void>;
}
interface RequestDeviceOptions {
filters?: BluetoothLEScanFilter[];
optionalServices?: BluetoothServiceUUID[];
acceptAllDevices?: boolean;
}
interface BluetoothLEScanFilter {
services?: BluetoothServiceUUID[];
name?: string;
namePrefix?: string;
manufacturerData?: BluetoothManufacturerDataFilter[];
serviceData?: BluetoothServiceDataFilter[];
}
interface BluetoothManufacturerDataFilter {
companyIdentifier: number;
dataPrefix?: BufferSource;
mask?: BufferSource;
}
interface BluetoothServiceDataFilter {
service: BluetoothServiceUUID;
dataPrefix?: BufferSource;
mask?: BufferSource;
}
type BluetoothServiceUUID = number | string;
type BluetoothCharacteristicUUID = number | string;
type BluetoothDescriptorUUID = number | string;
interface Bluetooth extends EventTarget {
getAvailability(): Promise<boolean>;
requestDevice(options?: RequestDeviceOptions): Promise<BluetoothDevice>;
getDevices(): Promise<BluetoothDevice[]>;
addEventListener(
type: 'availabilitychanged',
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void;
}
interface Navigator {
bluetooth: Bluetooth;
}

28
tsconfig.app.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

37
vite.config.ts Normal file
View file

@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteStaticCopy } from 'vite-plugin-static-copy'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const PYODIDE_EXCLUDE = [
'!**/*.{md,html}',
'!**/*.d.ts',
'!**/node_modules',
]
function viteStaticCopyPyodide() {
const pyodideDir = dirname(fileURLToPath(import.meta.resolve('pyodide')))
return viteStaticCopy({
targets: [
{
src: [join(pyodideDir, '*').replace(/\\/g, '/')].concat(PYODIDE_EXCLUDE),
dest: 'assets',
},
],
})
}
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), viteStaticCopyPyodide()],
optimizeDeps: {
exclude: ['pyodide'],
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
})