From 7250e0e586c200e5ad10cfdd7d3fce73cd12431c Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Thu, 26 Mar 2026 12:04:38 +0100 Subject: [PATCH] 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) --- .../ProgressMonitor/ProgressActions.tsx | 4 +- .../ProgressMonitor/ProgressMonitor.tsx | 27 +++ .../ProgressMonitor/StitchStepControl.tsx | 187 ++++++++++++++++++ src/services/BrotherPP1Service.ts | 7 + src/stores/useMachineStore.ts | 151 +++++++++++++- src/utils/errorCodeHelpers.ts | 18 ++ src/utils/machineStateHelpers.ts | 44 ++++- 7 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 src/components/ProgressMonitor/StitchStepControl.tsx diff --git a/src/components/ProgressMonitor/ProgressActions.tsx b/src/components/ProgressMonitor/ProgressActions.tsx index 401a9f2..89bfa34 100644 --- a/src/components/ProgressMonitor/ProgressActions.tsx +++ b/src/components/ProgressMonitor/ProgressActions.tsx @@ -17,6 +17,7 @@ interface ProgressActionsProps { machineStatus: MachineStatus; isDeleting: boolean; isMaskTraceComplete: boolean; + hasSewingProgress: boolean; onResumeSewing: () => void; onStartSewing: () => void; onStartMaskTrace: () => void; @@ -26,6 +27,7 @@ export function ProgressActions({ machineStatus, isDeleting, isMaskTraceComplete, + hasSewingProgress, onResumeSewing, onStartSewing, onStartMaskTrace, @@ -59,7 +61,7 @@ export function ProgressActions({ )} {/* Start Mask Trace - secondary action */} - {canStartMaskTrace(machineStatus) && ( + {canStartMaskTrace(machineStatus, hasSewingProgress) && ( + + + + + + + + + {/* Navigation buttons */} +
+ {colorBlocks.length > 0 && ( + + )} + {pausedStitchIndex !== null && displayStitch !== pausedStitchIndex && ( + + )} +
+ + ); +} diff --git a/src/services/BrotherPP1Service.ts b/src/services/BrotherPP1Service.ts index 2b75618..283db50 100644 --- a/src/services/BrotherPP1Service.ts +++ b/src/services/BrotherPP1Service.ts @@ -626,6 +626,13 @@ export class BrotherPP1Service { await this.sendCommand(Commands.MASK_TRACE, payload); } + async setStitchIndex(stitchIndex: number): Promise { + 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 { await this.sendCommand(Commands.START_SEWING); } diff --git a/src/stores/useMachineStore.ts b/src/stores/useMachineStore.ts index b0c35db..8dfbc17 100644 --- a/src/stores/useMachineStore.ts +++ b/src/stores/useMachineStore.ts @@ -9,7 +9,11 @@ import type { SewingProgress, } from "../types/machine"; import { MachineStatus, MachineStatusNames } from "../types/machine"; -import { SewingMachineError } from "../utils/errorCodeHelpers"; +import { + SewingMachineError, + getErrorStitchRollback, +} from "../utils/errorCodeHelpers"; +import { getMachineStateCategory } from "../utils/machineStateHelpers"; import { uuidToString } from "../services/PatternCacheService"; import { createStorageService } from "../platform"; import type { IStorageService } from "../platform/interfaces/IStorageService"; @@ -41,6 +45,11 @@ interface MachineState { isCommunicating: boolean; isDeleting: boolean; + // Step control state + adjustedStitchIndex: number | null; + lastRolledBackError: number | null; + pausedStitchIndex: number | null; // Position snapshot after pause + auto-rollback, before manual adjustments + // Polling control pollIntervalId: NodeJS.Timeout | null; serviceCountIntervalId: NodeJS.Timeout | null; @@ -56,6 +65,8 @@ interface MachineState { startSewing: () => Promise; resumeSewing: () => Promise; deletePattern: () => Promise; + setStitchPosition: (index: number) => Promise; + adjustStitchPosition: (offset: number) => Promise; // Initialization initialize: () => void; @@ -64,6 +75,7 @@ interface MachineState { _setupSubscriptions: () => void; _startPolling: () => void; _stopPolling: () => void; + _handleErrorStitchRollback: () => Promise; } export const useMachineStore = create((set, get) => ({ @@ -81,6 +93,9 @@ export const useMachineStore = create((set, get) => ({ isPairingError: false, isCommunicating: false, isDeleting: false, + adjustedStitchIndex: null, + lastRolledBackError: null, + pausedStitchIndex: null, pollIntervalId: null, serviceCountIntervalId: null, @@ -104,6 +119,14 @@ export const useMachineStore = create((set, get) => ({ machineError: state.error, }); + // Fetch sewing progress so we know if sewing was in progress before reconnect + try { + const progress = await service.getSewingProgress(); + set({ sewingProgress: progress }); + } catch { + // Not critical - polling will pick it up + } + // Check for resume possibility using cache store const { useMachineCacheStore } = await import("./useMachineCacheStore"); await useMachineCacheStore.getState().checkResume(); @@ -237,7 +260,12 @@ export const useMachineStore = create((set, get) => ({ if (!isConnected) return; try { - set({ error: null }); + set({ + error: null, + adjustedStitchIndex: null, + lastRolledBackError: null, + pausedStitchIndex: null, + }); await service.startSewing(); await refreshStatus(); } catch (err) { @@ -253,7 +281,12 @@ export const useMachineStore = create((set, get) => ({ if (!isConnected) return; try { - set({ error: null }); + set({ + error: null, + adjustedStitchIndex: null, + lastRolledBackError: null, + pausedStitchIndex: null, + }); await service.resumeSewing(); await refreshStatus(); } catch (err) { @@ -304,6 +337,64 @@ export const useMachineStore = create((set, get) => ({ } }, + // Set stitch position to an absolute index + setStitchPosition: async (index: number) => { + const { isConnected, service, patternInfo } = get(); + if (!isConnected) return; + + const totalStitches = patternInfo?.totalStitches || 0; + const clamped = Math.max(0, Math.min(index, totalStitches)); + + try { + await service.setStitchIndex(clamped); + set({ adjustedStitchIndex: clamped }); + // Refresh progress so UI reflects the new position + await get().refreshProgress(); + } catch (err) { + set({ + error: + err instanceof Error ? err.message : "Failed to set stitch position", + }); + } + }, + + // Adjust stitch position by a relative offset + adjustStitchPosition: async (offset: number) => { + const { sewingProgress, adjustedStitchIndex } = get(); + const currentIndex = + adjustedStitchIndex ?? sewingProgress?.currentStitch ?? 0; + await get().setStitchPosition(currentIndex + offset); + }, + + // Handle automatic stitch rollback for thread errors + _handleErrorStitchRollback: async () => { + const { machineError, sewingProgress, service, lastRolledBackError } = + get(); + + const rollback = getErrorStitchRollback(machineError); + if (rollback === null) return; + if (machineError === lastRolledBackError) return; + + const currentStitch = sewingProgress?.currentStitch ?? 0; + const newIndex = Math.max(0, currentStitch - rollback); + + console.log( + `[StepControl] Auto-rollback: stitch ${currentStitch} -> ${newIndex} (error 0x${machineError.toString(16)}, rollback ${rollback})`, + ); + + try { + await service.setStitchIndex(newIndex); + set({ + adjustedStitchIndex: newIndex, + lastRolledBackError: machineError, + }); + // Immediately refresh progress so subsequent polls see consistent state + await get().refreshProgress(); + } catch (err) { + console.error("[StepControl] Failed to rollback stitch position:", err); + } + }, + // Initialize the store (call once from App component) initialize: () => { get()._setupSubscriptions(); @@ -359,7 +450,7 @@ export const useMachineStore = create((set, get) => ({ status === MachineStatus.MASK_TRACING || status === MachineStatus.SEWING_DATA_RECEIVE ) { - return 500; + return 1000; } else if ( status === MachineStatus.COLOR_CHANGE_WAIT || status === MachineStatus.MASK_TRACE_LOCK_WAIT || @@ -374,11 +465,55 @@ export const useMachineStore = create((set, get) => ({ const poll = async () => { await refreshStatus(); + const currentState = get(); + const category = getMachineStateCategory(currentState.machineStatus); + // Refresh progress during sewing - if (get().machineStatus === MachineStatus.SEWING) { + if (currentState.machineStatus === MachineStatus.SEWING) { await refreshProgress(); } + // Reset step control state when machine is actively sewing + if (category === "active") { + if ( + currentState.adjustedStitchIndex !== null || + currentState.lastRolledBackError !== null || + currentState.pausedStitchIndex !== null + ) { + set({ + adjustedStitchIndex: null, + lastRolledBackError: null, + pausedStitchIndex: null, + }); + } + } + // Reset rollback tracking when error clears while still paused + else if ( + currentState.lastRolledBackError !== null && + currentState.machineError === SewingMachineError.None + ) { + set({ lastRolledBackError: null }); + } + + // Auto-rollback for thread errors when machine is interrupted or paused mid-sew + // Only runs once on entering paused state (when pausedStitchIndex is not yet set) + const isInterruptedOrPausedMidSew = + category === "interrupted" || + (currentState.machineStatus === MachineStatus.SEWING_WAIT && + (currentState.sewingProgress?.currentStitch ?? 0) > 0); + if (isInterruptedOrPausedMidSew && get().pausedStitchIndex === null) { + // Refresh progress so rollback has accurate current stitch + await get().refreshProgress(); + await get()._handleErrorStitchRollback(); + + // Snapshot the paused position (after rollback, before manual adjustments) + const postRollbackStitch = + get().adjustedStitchIndex ?? + get().sewingProgress?.currentStitch ?? + 0; + set({ pausedStitchIndex: postRollbackStitch }); + } + // follows the apps logic: // Check if we have a cached pattern and pattern info needs refreshing const { useMachineCacheStore } = await import("./useMachineCacheStore"); @@ -434,6 +569,12 @@ export const usePatternInfo = () => useMachineStore((state) => state.patternInfo); export const useSewingProgress = () => useMachineStore((state) => state.sewingProgress); +export const useAdjustedStitchIndex = () => + useMachineStore((state) => state.adjustedStitchIndex); +export const useLastRolledBackError = () => + useMachineStore((state) => state.lastRolledBackError); +export const usePausedStitchIndex = () => + useMachineStore((state) => state.pausedStitchIndex); // Derived state: pattern is uploaded if machine has pattern info export const usePatternUploaded = () => useMachineStore((state) => state.patternInfo !== null); diff --git a/src/utils/errorCodeHelpers.ts b/src/utils/errorCodeHelpers.ts index 0d1e7c4..b157506 100644 --- a/src/utils/errorCodeHelpers.ts +++ b/src/utils/errorCodeHelpers.ts @@ -412,6 +412,24 @@ export function getErrorDetails( }; } +/** + * Get the number of stitches to roll back for a given error code. + * Returns null if the error does not trigger automatic rollback. + * Based on Artspira ChangeStitchForError logic. + */ +export function getErrorStitchRollback(errorCode: number): number | null { + switch (errorCode) { + case SewingMachineError.UpperThreadError: + return 6; + case SewingMachineError.LowerThreadError: + return 2; + case SewingMachineError.UpperThreadSewingStartError: + return 21; + default: + return null; + } +} + /** * Export ErrorInfo type for use in other files */ diff --git a/src/utils/machineStateHelpers.ts b/src/utils/machineStateHelpers.ts index 231c84c..a933281 100644 --- a/src/utils/machineStateHelpers.ts +++ b/src/utils/machineStateHelpers.ts @@ -107,14 +107,21 @@ export function canStartSewing(status: MachineStatus): boolean { /** * Determines if mask trace can be started in the current state. + * When hasSewingProgress is true, SEWING_WAIT means the machine is paused mid-sew, + * not waiting for initial start - mask trace should not be offered. */ -export function canStartMaskTrace(status: MachineStatus): boolean { - // Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace - return ( - status === MachineStatus.IDLE || - status === MachineStatus.SEWING_WAIT || - status === MachineStatus.MASK_TRACE_COMPLETE - ); +export function canStartMaskTrace( + status: MachineStatus, + hasSewingProgress = false, +): boolean { + if (status === MachineStatus.IDLE || status === MachineStatus.MASK_TRACE_COMPLETE) { + return true; + } + // Only allow mask trace in SEWING_WAIT if sewing hasn't started yet + if (status === MachineStatus.SEWING_WAIT && !hasSewingProgress) { + return true; + } + return false; } /** @@ -127,6 +134,29 @@ export function canResumeSewing(status: MachineStatus): boolean { return category === MachineStateCategory.INTERRUPTED; } +/** + * Determines if the step control UI should be shown. + * Allows manual stitch position adjustment when machine is paused/stopped/interrupted, + * or in SEWING_WAIT if sewing has already started (currentStitch > 0). + */ +export function canShowStepControl( + status: MachineStatus, + hasSewingProgress: boolean, +): boolean { + if ( + status === MachineStatus.PAUSE || + status === MachineStatus.STOP || + status === MachineStatus.SEWING_INTERRUPTION + ) { + return true; + } + // SEWING_WAIT is also the paused state; show controls only if sewing already started + if (status === MachineStatus.SEWING_WAIT && hasSewingProgress) { + return true; + } + return false; +} + /** * Determines if disconnect should show a confirmation dialog. * Confirms if disconnecting during active operation or while waiting.