mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
initial
This commit is contained in:
commit
acdf87b237
32 changed files with 7202 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
150
README.md
Normal 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
23
eslint.config.js
Normal 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
13
index.html
Normal 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
3223
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/pystitch-1.0.0-py3-none-any.whl
Normal file
BIN
public/pystitch-1.0.0-py3-none-any.whl
Normal file
Binary file not shown.
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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
645
src/App.css
Normal 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
107
src/App.tsx
Normal 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
1
src/assets/react.svg
Normal 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 |
73
src/components/ConfirmDialog.tsx
Normal file
73
src/components/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/components/FileUpload.tsx
Normal file
136
src/components/FileUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/MachineConnection.tsx
Normal file
134
src/components/MachineConnection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
src/components/PatternCanvas.tsx
Normal file
284
src/components/PatternCanvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
src/components/ProgressMonitor.tsx
Normal file
310
src/components/ProgressMonitor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
355
src/hooks/useBrotherMachine.ts
Normal file
355
src/hooks/useBrotherMachine.ts
Normal 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
13
src/index.css
Normal 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
10
src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
452
src/services/BrotherPP1Service.ts
Normal file
452
src/services/BrotherPP1Service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/services/PatternCacheService.ts
Normal file
155
src/services/PatternCacheService.ts
Normal 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
103
src/types/machine.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
100
src/utils/errorCodeHelpers.ts
Normal file
100
src/utils/errorCodeHelpers.ts
Normal 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;
|
||||||
|
}
|
||||||
184
src/utils/machineStateHelpers.ts
Normal file
184
src/utils/machineStateHelpers.ts
Normal 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
128
src/utils/penParser.ts
Normal 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;
|
||||||
|
}
|
||||||
90
src/utils/pyodideLoader.ts
Normal file
90
src/utils/pyodideLoader.ts
Normal 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();
|
||||||
233
src/utils/pystitchConverter.ts
Normal file
233
src/utils/pystitchConverter.ts
Normal 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
125
src/web-bluetooth.d.ts
vendored
Normal 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
28
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
37
vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue