respira/src/services/BrotherPP1Service.ts
Jan-Henrik Bruhn 7250e0e586 feature: Add stitch step control for error recovery and manual positioning
Implements automatic stitch rollback on thread errors (upper thread: -6,
lower thread: -2, sewing start: -21) and manual step controls to adjust
stitch position when machine is paused/stopped/interrupted. Adds UI with
step buttons (-100/-10/-1/+1/+10/+100), thread start jump, and current
stitch reset. Uses existing NEEDLE_MODE_INSTRUCTIONS (0x0709) BLE command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:04:38 +01:00

786 lines
24 KiB
TypeScript

import type {
MachineInfo,
PatternInfo,
SewingProgress,
} from "../types/machine";
import { MachineStatus } from "../types/machine";
// Custom error for pairing issues
export class BluetoothPairingError extends Error {
constructor(message: string) {
super(message);
this.name = "BluetoothPairingError";
}
}
// 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,
REGULAR_INSPECTION: 0x0103,
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,
HOOP_AVOIDANCE: 0x070f,
START_SEWING: 0x070e,
MASK_TRACE_1: 0x0710,
EMB_ORG_POINT: 0x0800,
FIRM_UPDATE_START: 0x0b00,
SET_SETTING_REST: 0x0c00,
SET_SETTING_SEND: 0x0c01,
MACHINE_SETTING_INFO: 0x0c02,
SEND_DATA_INFO: 0x1200,
SEND_DATA: 0x1201,
CLEAR_ERROR: 0x1300,
ERROR_LOG_REPLY: 0x1301,
};
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;
private isCommunicating = false;
private isInitialConnection = false;
private communicationCallbacks: Set<(isCommunicating: boolean) => void> =
new Set();
private disconnectCallbacks: Set<() => void> = new Set();
/**
* Subscribe to communication state changes
* @param callback Function called when communication state changes
* @returns Unsubscribe function
*/
onCommunicationChange(
callback: (isCommunicating: boolean) => void,
): () => void {
this.communicationCallbacks.add(callback);
// Immediately call with current state
callback(this.isCommunicating);
return () => {
this.communicationCallbacks.delete(callback);
};
}
/**
* Subscribe to disconnect events
* @param callback Function called when device disconnects
* @returns Unsubscribe function
*/
onDisconnect(callback: () => void): () => void {
this.disconnectCallbacks.add(callback);
return () => {
this.disconnectCallbacks.delete(callback);
};
}
private setCommunicating(value: boolean) {
if (this.isCommunicating !== value) {
this.isCommunicating = value;
this.communicationCallbacks.forEach((callback) => callback(value));
}
}
private handleDisconnect() {
console.log("[BrotherPP1Service] Device disconnected");
this.server = null;
this.writeCharacteristic = null;
this.readCharacteristic = null;
this.commandQueue = [];
this.isProcessingQueue = false;
this.setCommunicating(false);
this.disconnectCallbacks.forEach((callback) => callback());
}
async connect(): Promise<void> {
this.isInitialConnection = true;
try {
this.device = await navigator.bluetooth.requestDevice({
filters: [{ services: [SERVICE_UUID] }],
});
if (!this.device.gatt) {
throw new Error("GATT not available");
}
// Listen for disconnection events
this.device.addEventListener("gattserverdisconnected", () => {
this.handleDisconnect();
});
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");
// Validate connection by sending a test command
// If the device is not paired, it won't respond properly
console.log("Validating connection with test command...");
try {
await this.getMachineState();
console.log(
"Connection validation successful - device is properly paired",
);
} catch (e) {
console.log("Connection validation failed:", e);
// Disconnect to clean up
if (this.server) {
this.server.disconnect();
}
throw e;
}
} finally {
this.isInitialConnection = false;
}
}
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;
this.setCommunicating(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;
this.setCommunicating(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 getCommandName(cmdId: number): string {
const names: Record<number, string> = {
[Commands.MACHINE_INFO]: "MACHINE_INFO",
[Commands.MACHINE_STATE]: "MACHINE_STATE",
[Commands.SERVICE_COUNT]: "SERVICE_COUNT",
[Commands.REGULAR_INSPECTION]: "REGULAR_INSPECTION",
[Commands.PATTERN_UUID_REQUEST]: "PATTERN_UUID_REQUEST",
[Commands.MASK_TRACE]: "MASK_TRACE",
[Commands.LAYOUT_SEND]: "LAYOUT_SEND",
[Commands.EMB_SEWING_INFO_REQUEST]: "EMB_SEWING_INFO_REQUEST",
[Commands.PATTERN_SEWING_INFO]: "PATTERN_SEWING_INFO",
[Commands.EMB_SEWING_DATA_DELETE]: "EMB_SEWING_DATA_DELETE",
[Commands.NEEDLE_MODE_INSTRUCTIONS]: "NEEDLE_MODE_INSTRUCTIONS",
[Commands.EMB_UUID_SEND]: "EMB_UUID_SEND",
[Commands.RESUME_FLAG_REQUEST]: "RESUME_FLAG_REQUEST",
[Commands.RESUME]: "RESUME",
[Commands.HOOP_AVOIDANCE]: "HOOP_AVOIDANCE",
[Commands.START_SEWING]: "START_SEWING",
[Commands.MASK_TRACE_1]: "MASK_TRACE_1",
[Commands.EMB_ORG_POINT]: "EMB_ORG_POINT",
[Commands.FIRM_UPDATE_START]: "FIRM_UPDATE_START",
[Commands.SET_SETTING_REST]: "SET_SETTING_REST",
[Commands.SET_SETTING_SEND]: "SET_SETTING_SEND",
[Commands.MACHINE_SETTING_INFO]: "MACHINE_SETTING_INFO",
[Commands.SEND_DATA_INFO]: "SEND_DATA_INFO",
[Commands.SEND_DATA]: "SEND_DATA",
[Commands.CLEAR_ERROR]: "CLEAR_ERROR",
[Commands.ERROR_LOG_REPLY]: "ERROR_LOG_REPLY",
};
return names[cmdId] || `UNKNOWN(0x${cmdId.toString(16).padStart(4, "0")})`;
}
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);
const hexData = Array.from(command)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ");
console.log(
`[TX] ${this.getCommandName(cmdId)} (0x${cmdId.toString(16).padStart(4, "0")}):`,
hexData,
);
try {
// Write command
await this.writeCharacteristic.writeValueWithResponse(command);
// Longer delay to allow machine to prepare response
await new Promise((resolve) => setTimeout(resolve, 50));
// Read response
const responseData = await this.readCharacteristic.readValue();
const response = new Uint8Array(responseData.buffer);
const hexResponse = Array.from(response)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ");
// Detect pairing issues during initial connection - empty or invalid response
if (this.isInitialConnection) {
if (response.length === 0) {
console.log(
"[BrotherPP1] Empty response received - device likely not paired",
);
throw new BluetoothPairingError(
"Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
);
}
// Check for invalid response (less than 3 bytes means no proper command response)
if (response.length < 3) {
console.log(
"[BrotherPP1] Invalid response length:",
response.length,
);
throw new BluetoothPairingError(
"Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
);
}
}
// Parse response
let parsed = "";
if (response.length >= 3) {
const respCmdId = (response[0] << 8) | response[1];
const status = response[2];
parsed = ` | Status: 0x${status.toString(16).padStart(2, "0")}`;
if (respCmdId !== cmdId) {
parsed += ` | WARNING: Response cmd 0x${respCmdId.toString(16).padStart(4, "0")} != request cmd`;
}
}
console.log(`[RX] ${this.getCommandName(cmdId)}:`, hexResponse, parsed);
return response;
} catch (error) {
// Detect pairing issues - only during initial connection
if (this.isInitialConnection && error instanceof Error) {
const errorMsg = error.message.toLowerCase();
if (
errorMsg.includes("gatt server is disconnected") ||
(errorMsg.includes("writevaluewithresponse") &&
errorMsg.includes("gatt server is disconnected"))
) {
throw new BluetoothPairingError(
"Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
);
}
}
throw error;
}
});
}
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();
// Fetch service count data (cumulative statistics)
let serviceCount: number | undefined;
let totalCount: number | undefined;
try {
const serviceData = await this.getServiceCount();
serviceCount = serviceData.serviceCount;
totalCount = serviceData.totalCount;
} catch (err) {
console.warn("[BrotherPP1] Failed to fetch service count:", err);
}
return {
serialNumber,
modelNumber: modelCode,
softwareVersion: `${(swVersion / 100).toFixed(2)}.${data[35]}`,
bluetoothVersion: btVersion,
maxWidth,
maxHeight,
macAddress,
serviceCount,
totalCount,
};
}
async getServiceCount(): Promise<{
serviceCount: number;
totalCount: number;
}> {
const response = await this.sendCommand(Commands.SERVICE_COUNT);
const data = response.slice(2);
// Read uint32 values in little-endian format
const readUInt32LE = (offset: number) =>
data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
return {
serviceCount: readUInt32LE(0), // Bytes 0-3
totalCount: readUInt32LE(4), // Bytes 4-7
};
}
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);
const patternInfo = {
boundLeft: readInt16LE(0),
boundTop: readInt16LE(2),
boundRight: readInt16LE(4),
boundBottom: readInt16LE(6),
totalTime: readUInt16LE(8),
totalStitches: readUInt16LE(10),
speed: readUInt16LE(12),
};
console.log("[BrotherPP1] Pattern Info Response:", {
rawData: Array.from(data)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" "),
parsed: patternInfo,
});
return patternInfo;
}
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<void> {
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;
// Official app approach: Send chunk without waiting for response
await this.sendCommandNoResponse(Commands.SEND_DATA, payload);
}
private async sendCommandNoResponse(
cmdId: number,
data: Uint8Array = new Uint8Array(),
): Promise<void> {
return this.enqueueCommand(async () => {
if (!this.writeCharacteristic) {
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);
const hexData = Array.from(command)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ");
console.log(
`[TX-NoResp] ${this.getCommandName(cmdId)} (0x${cmdId.toString(16).padStart(4, "0")}):`,
hexData.substring(0, 100) + (hexData.length > 100 ? "..." : ""),
);
// Write without reading response
await this.writeCharacteristic.writeValueWithResponse(command);
// Small delay to allow BLE buffer to clear
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
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,
boundLeft: number,
boundTop: number,
boundRight: number,
boundBottom: number,
): Promise<void> {
const payload = new Uint8Array(26);
const writeInt16LE = (offset: number, value: number) => {
payload[offset] = value & 0xff;
payload[offset + 1] = (value >> 8) & 0xff;
};
// Position/transformation parameters (12 bytes)
writeInt16LE(0, moveX);
writeInt16LE(2, moveY);
writeInt16LE(4, sizeX);
writeInt16LE(6, sizeY);
writeInt16LE(8, rotate);
payload[10] = flip;
payload[11] = frame;
// Pattern bounds (8 bytes)
writeInt16LE(12, boundLeft);
writeInt16LE(14, boundTop);
writeInt16LE(16, boundRight);
writeInt16LE(18, boundBottom);
// Repeat moveX and moveY at the end (6 bytes)
writeInt16LE(20, moveX);
writeInt16LE(22, moveY);
payload[24] = flip;
payload[25] = frame;
console.log("[DEBUG] Layout bounds:", {
boundLeft,
boundTop,
boundRight,
boundBottom,
moveX,
moveY,
sizeX,
sizeY,
});
await this.sendCommand(Commands.LAYOUT_SEND, payload);
}
async getMachineSettings(): Promise<Uint8Array> {
return await this.sendCommand(Commands.MACHINE_SETTING_INFO);
}
async startMaskTrace(): Promise<void> {
// Query machine settings before starting mask trace (as per official app)
await this.getMachineSettings();
const payload = new Uint8Array([0x00]);
await this.sendCommand(Commands.MASK_TRACE, payload);
}
async setStitchIndex(stitchIndex: number): Promise<void> {
const payload = new Uint8Array(2);
payload[0] = stitchIndex & 0xff; // Low byte
payload[1] = (stitchIndex >> 8) & 0xff; // High byte
await this.sendCommand(Commands.NEEDLE_MODE_INSTRUCTIONS, 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,
bounds?: { minX: number; maxX: number; minY: number; maxY: number },
patternOffset?: { x: number; y: number },
): 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;
// Send all chunks without waiting for responses (official app approach)
while (offset < data.length) {
const chunk = data.slice(
offset,
Math.min(offset + chunkSize, data.length),
);
await this.sendDataChunk(offset, chunk);
offset += chunk.length;
if (onProgress) {
onProgress((offset / data.length) * 100);
}
}
// Wait a bit for machine to finish processing chunks
await new Promise((resolve) => setTimeout(resolve, 500));
// Use provided bounds or default to 0
const boundLeft = bounds?.minX ?? 0;
const boundTop = bounds?.minY ?? 0;
const boundRight = bounds?.maxX ?? 0;
const boundBottom = bounds?.maxY ?? 0;
// Calculate move offset based on user-defined pattern offset or auto-center
let moveX: number;
let moveY: number;
if (patternOffset) {
// Use user-defined offset from canvas dragging
// Pattern offset is in canvas coordinates (0,0 at hoop center)
// We need to calculate the move that positions pattern's center at the offset position
const patternCenterX = (boundLeft + boundRight) / 2;
const patternCenterY = (boundTop + boundBottom) / 2;
// moveX/moveY define where the pattern center should be
// offset.x/y is where user dragged the pattern to (relative to hoop center)
moveX = patternOffset.x - patternCenterX;
moveY = patternOffset.y - patternCenterY;
console.log("[LAYOUT] Using user-defined offset:", {
patternOffset,
patternCenter: { x: patternCenterX, y: patternCenterY },
moveX,
moveY,
});
} else {
// Auto-center: position pattern center at machine center (0, 0)
const patternCenterX = (boundLeft + boundRight) / 2;
const patternCenterY = (boundTop + boundBottom) / 2;
moveX = -patternCenterX;
moveY = -patternCenterY;
console.log("[LAYOUT] Auto-centering pattern:", { moveX, moveY });
}
// Send layout with actual pattern bounds
// sizeX/sizeY are scaling factors (100 = 100% = no scaling)
await this.sendLayout(
Math.round(moveX), // moveX - position the pattern
Math.round(moveY), // moveY - position the pattern
100, // sizeX (100% - no scaling)
100, // sizeY (100% - no scaling)
0, // rotate
0, // flip
1, // frame
boundLeft,
boundTop,
boundRight,
boundBottom,
);
// Generate random UUID
const uuid = crypto.getRandomValues(new Uint8Array(16));
await this.sendUUID(uuid);
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;
}
}
}