Merge pull request #84 from jhbruhn/step-control

feature: step control
This commit is contained in:
Jan-Henrik Bruhn 2026-03-26 12:32:41 +01:00 committed by GitHub
commit 3e30909311
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 457 additions and 72 deletions

View file

@ -11,7 +11,12 @@
"Bash(gh issue create:*)", "Bash(gh issue create:*)",
"Bash(gh label create:*)", "Bash(gh label create:*)",
"Bash(gh issue view:*)", "Bash(gh issue view:*)",
"Bash(gh pr view:*)" "Bash(gh pr view:*)",
"Bash(bd list:*)",
"Bash(bd status:*)",
"Bash(bd create:*)",
"Bash(bd dep add:*)",
"Bash(bd ready:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View file

@ -187,7 +187,9 @@ export function AppHeader() {
<button <button
className={cn( className={cn(
"inline-flex items-center rounded-full border border-transparent bg-destructive text-white px-2.5 py-1.5 text-xs font-semibold gap-1.5 cursor-pointer hover:bg-destructive/90 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2", "inline-flex items-center rounded-full border border-transparent bg-destructive text-white px-2.5 py-1.5 text-xs font-semibold gap-1.5 cursor-pointer hover:bg-destructive/90 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2",
machineErrorMessage || pyodideError machineErrorMessage ||
pyodideError ||
hasError(machineError)
? "animate-pulse hover:animate-none" ? "animate-pulse hover:animate-none"
: "invisible pointer-events-none", : "invisible pointer-events-none",
)} )}
@ -228,7 +230,9 @@ export function AppHeader() {
</PopoverTrigger> </PopoverTrigger>
{/* Error popover content - unchanged */} {/* Error popover content - unchanged */}
{(machineErrorMessage || pyodideError) && ( {(machineErrorMessage ||
pyodideError ||
hasError(machineError)) && (
<ErrorPopoverContent <ErrorPopoverContent
machineError={ machineError={
machineError != 0xdd ? machineError : undefined machineError != 0xdd ? machineError : undefined

View file

@ -17,6 +17,7 @@ interface ProgressActionsProps {
machineStatus: MachineStatus; machineStatus: MachineStatus;
isDeleting: boolean; isDeleting: boolean;
isMaskTraceComplete: boolean; isMaskTraceComplete: boolean;
hasSewingProgress: boolean;
onResumeSewing: () => void; onResumeSewing: () => void;
onStartSewing: () => void; onStartSewing: () => void;
onStartMaskTrace: () => void; onStartMaskTrace: () => void;
@ -26,6 +27,7 @@ export function ProgressActions({
machineStatus, machineStatus,
isDeleting, isDeleting,
isMaskTraceComplete, isMaskTraceComplete,
hasSewingProgress,
onResumeSewing, onResumeSewing,
onStartSewing, onStartSewing,
onStartMaskTrace, onStartMaskTrace,
@ -59,7 +61,7 @@ export function ProgressActions({
)} )}
{/* Start Mask Trace - secondary action */} {/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && ( {canStartMaskTrace(machineStatus, hasSewingProgress) && (
<Button <Button
onClick={onStartMaskTrace} onClick={onStartMaskTrace}
disabled={isDeleting} disabled={isDeleting}

View file

@ -23,10 +23,12 @@ import {
CardDescription, CardDescription,
CardContent, CardContent,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { canShowStepControl } from "../../utils/machineStateHelpers";
import { ProgressStats } from "./ProgressStats"; import { ProgressStats } from "./ProgressStats";
import { ProgressSection } from "./ProgressSection"; import { ProgressSection } from "./ProgressSection";
import { ColorBlockList } from "./ColorBlockList"; import { ColorBlockList } from "./ColorBlockList";
import { ProgressActions } from "./ProgressActions"; import { ProgressActions } from "./ProgressActions";
import { StitchStepControl } from "./StitchStepControl";
export function ProgressMonitor() { export function ProgressMonitor() {
// Machine store // Machine store
@ -35,18 +37,28 @@ export function ProgressMonitor() {
patternInfo, patternInfo,
sewingProgress, sewingProgress,
isDeleting, isDeleting,
adjustedStitchIndex,
lastRolledBackError,
pausedStitchIndex,
startMaskTrace, startMaskTrace,
startSewing, startSewing,
resumeSewing, resumeSewing,
adjustStitchPosition,
setStitchPosition,
} = useMachineStore( } = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
machineStatus: state.machineStatus, machineStatus: state.machineStatus,
patternInfo: state.patternInfo, patternInfo: state.patternInfo,
sewingProgress: state.sewingProgress, sewingProgress: state.sewingProgress,
isDeleting: state.isDeleting, isDeleting: state.isDeleting,
adjustedStitchIndex: state.adjustedStitchIndex,
lastRolledBackError: state.lastRolledBackError,
pausedStitchIndex: state.pausedStitchIndex,
startMaskTrace: state.startMaskTrace, startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing, startSewing: state.startSewing,
resumeSewing: state.resumeSewing, resumeSewing: state.resumeSewing,
adjustStitchPosition: state.adjustStitchPosition,
setStitchPosition: state.setStitchPosition,
})), })),
); );
@ -66,19 +78,18 @@ export function ProgressMonitor() {
: patternInfo.totalStitches : patternInfo.totalStitches
: 0; : 0;
// Use adjustedStitchIndex (from step control) when available, otherwise machine-reported
const currentStitch =
adjustedStitchIndex ?? sewingProgress?.currentStitch ?? 0;
const progressPercent = const progressPercent =
totalStitches > 0 totalStitches > 0 ? (currentStitch / totalStitches) * 100 : 0;
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
: 0;
// Calculate color block information from decoded penStitches // Calculate color block information from decoded penStitches
const colorBlocks = useMemo( const colorBlocks = useMemo(
() => calculateColorBlocks(displayPattern), () => calculateColorBlocks(displayPattern),
[displayPattern], [displayPattern],
); );
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;
const currentBlockIndex = findCurrentBlockIndex(colorBlocks, currentStitch); const currentBlockIndex = findCurrentBlockIndex(colorBlocks, currentStitch);
// Calculate time based on color blocks (matches Brother app calculation) // Calculate time based on color blocks (matches Brother app calculation)
@ -115,7 +126,9 @@ export function ProgressMonitor() {
{/* Pattern Info */} {/* Pattern Info */}
{patternInfo && ( {patternInfo && (
<ProgressStats <ProgressStats
currentStitch={currentStitch}
totalStitches={totalStitches} totalStitches={totalStitches}
elapsedMinutes={elapsedMinutes}
totalMinutes={totalMinutes} totalMinutes={totalMinutes}
speed={patternInfo.speed} speed={patternInfo.speed}
/> />
@ -123,13 +136,7 @@ export function ProgressMonitor() {
{/* Progress Bar */} {/* Progress Bar */}
{sewingProgress && ( {sewingProgress && (
<ProgressSection <ProgressSection progressPercent={progressPercent} />
currentStitch={sewingProgress.currentStitch}
totalStitches={totalStitches}
elapsedMinutes={elapsedMinutes}
totalMinutes={totalMinutes}
progressPercent={progressPercent}
/>
)} )}
{/* Color Blocks */} {/* Color Blocks */}
@ -140,11 +147,26 @@ export function ProgressMonitor() {
currentBlockRef={currentBlockRef} currentBlockRef={currentBlockRef}
/> />
{/* Step control for paused/stopped/error states */}
{canShowStepControl(machineStatus, currentStitch > 0) && (
<StitchStepControl
currentStitch={currentStitch}
adjustedStitchIndex={adjustedStitchIndex}
pausedStitchIndex={pausedStitchIndex}
totalStitches={totalStitches}
lastRolledBackError={lastRolledBackError}
colorBlocks={colorBlocks}
onAdjustPosition={adjustStitchPosition}
onSetPosition={setStitchPosition}
/>
)}
{/* Action buttons */} {/* Action buttons */}
<ProgressActions <ProgressActions
machineStatus={machineStatus} machineStatus={machineStatus}
isDeleting={isDeleting} isDeleting={isDeleting}
isMaskTraceComplete={isMaskTraceComplete} isMaskTraceComplete={isMaskTraceComplete}
hasSewingProgress={currentStitch > 0}
onResumeSewing={resumeSewing} onResumeSewing={resumeSewing}
onStartSewing={startSewing} onStartSewing={startSewing}
onStartMaskTrace={startMaskTrace} onStartMaskTrace={startMaskTrace}

View file

@ -1,49 +1,22 @@
/** /**
* ProgressSection Component * ProgressSection Component
* *
* Displays the progress bar and current/total stitch information * Displays the progress bar
*/ */
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
interface ProgressSectionProps { interface ProgressSectionProps {
currentStitch: number;
totalStitches: number;
elapsedMinutes: number;
totalMinutes: number;
progressPercent: number; progressPercent: number;
} }
export function ProgressSection({ export function ProgressSection({ progressPercent }: ProgressSectionProps) {
currentStitch,
totalStitches,
elapsedMinutes,
totalMinutes,
progressPercent,
}: ProgressSectionProps) {
return ( return (
<div className="mb-3"> <div className="mb-3">
<Progress <Progress
value={progressPercent} value={progressPercent}
className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800" className="h-3 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
/> />
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Current Stitch
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{currentStitch.toLocaleString()} / {totalStitches.toLocaleString()}
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Time</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{elapsedMinutes} / {totalMinutes} min
</span>
</div>
</div>
</div> </div>
); );
} }

View file

@ -1,36 +1,36 @@
/** /**
* ProgressStats Component * ProgressStats Component
* *
* Displays three stat cards: total stitches, total time, and speed * Displays three stat cards: stitches (current/total), time (elapsed/total), and speed
*/ */
interface ProgressStatsProps { interface ProgressStatsProps {
currentStitch: number;
totalStitches: number; totalStitches: number;
elapsedMinutes: number;
totalMinutes: number; totalMinutes: number;
speed: number; speed: number;
} }
export function ProgressStats({ export function ProgressStats({
currentStitch,
totalStitches, totalStitches,
elapsedMinutes,
totalMinutes, totalMinutes,
speed, speed,
}: ProgressStatsProps) { }: ProgressStatsProps) {
return ( return (
<div className="grid grid-cols-3 gap-2 text-xs mb-3"> <div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{totalStitches.toLocaleString()} {currentStitch.toLocaleString()} / {totalStitches.toLocaleString()}
</span> </span>
</div> </div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">Time</span>
Total Time
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{totalMinutes} min {elapsedMinutes} / {totalMinutes} min
</span> </span>
</div> </div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">

View file

@ -0,0 +1,187 @@
/**
* StitchStepControl Component
*
* Compact stitch position control shown when machine is paused/stopped/interrupted.
* Allows stepping forward/backward by 1, 10, or 100 stitches,
* jumping to thread color boundaries, and resetting to current position.
*/
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
SwatchIcon,
ArrowUturnLeftIcon,
} from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import {
getErrorStitchRollback,
getErrorMessage,
} from "../../utils/errorCodeHelpers";
import type { ColorBlock } from "./types";
import { findCurrentBlockIndex } from "../../utils/colorBlockHelpers";
interface StitchStepControlProps {
currentStitch: number;
adjustedStitchIndex: number | null;
pausedStitchIndex: number | null;
totalStitches: number;
lastRolledBackError: number | null;
colorBlocks: ColorBlock[];
onAdjustPosition: (offset: number) => void;
onSetPosition: (index: number) => void;
}
export function StitchStepControl({
currentStitch,
adjustedStitchIndex,
pausedStitchIndex,
totalStitches,
lastRolledBackError,
colorBlocks,
onAdjustPosition,
onSetPosition,
}: StitchStepControlProps) {
const displayStitch = adjustedStitchIndex ?? currentStitch;
const handleGoToThreadStart = () => {
const blockIndex = findCurrentBlockIndex(colorBlocks, displayStitch);
if (blockIndex >= 0) {
onSetPosition(colorBlocks[blockIndex].startStitch);
}
};
const handleGoToPausedStitch = () => {
if (pausedStitchIndex !== null) {
onSetPosition(pausedStitchIndex);
}
};
const rollbackAmount = lastRolledBackError
? getErrorStitchRollback(lastRolledBackError)
: null;
const rollbackErrorName = lastRolledBackError
? getErrorMessage(lastRolledBackError)
: null;
const showGoToPaused =
pausedStitchIndex !== null && displayStitch !== pausedStitchIndex;
return (
<div className="mb-3 bg-gray-200 dark:bg-gray-700/50 px-3 py-2 rounded-lg">
{/* Header: label + stitch count on one line */}
<div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Stitch Position
</span>
<span>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100 tabular-nums">
{displayStitch.toLocaleString()}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
/ {totalStitches.toLocaleString()}
</span>
</span>
</div>
{/* Rollback info */}
{rollbackAmount !== null && rollbackErrorName && (
<div className="text-xs text-amber-600 dark:text-amber-400 text-center mb-1.5">
Moved back {rollbackAmount} stitches (
{rollbackErrorName.toLowerCase()})
</div>
)}
{/* Step buttons + navigation in one row */}
<div className="flex items-center justify-center gap-1">
{colorBlocks.length > 0 && (
<Button
variant="outline"
size="icon-sm"
onClick={handleGoToThreadStart}
title="Go to the beginning of the selected thread color"
aria-label="Go to the beginning of the selected thread color"
>
<SwatchIcon className="w-4 h-4" />
</Button>
)}
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(-100)}
disabled={displayStitch <= 0}
aria-label="Back 100 stitches"
title="-100"
>
<ChevronDoubleLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(-10)}
disabled={displayStitch <= 0}
aria-label="Back 10 stitches"
title="-10"
>
<ChevronLeftIcon className="w-4 h-4 -mr-1" />
<ChevronLeftIcon className="w-4 h-4 -ml-1" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(-1)}
disabled={displayStitch <= 0}
aria-label="Back 1 stitch"
title="-1"
>
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(1)}
disabled={displayStitch >= totalStitches}
aria-label="Forward 1 stitch"
title="+1"
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(10)}
disabled={displayStitch >= totalStitches}
aria-label="Forward 10 stitches"
title="+10"
>
<ChevronRightIcon className="w-4 h-4 -mr-1" />
<ChevronRightIcon className="w-4 h-4 -ml-1" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(100)}
disabled={displayStitch >= totalStitches}
aria-label="Forward 100 stitches"
title="+100"
>
<ChevronDoubleRightIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={handleGoToPausedStitch}
disabled={!showGoToPaused}
title="Go to the current stitch"
aria-label="Go to the current stitch"
>
<ArrowUturnLeftIcon className="w-4 h-4" />
</Button>
</div>
</div>
);
}

View file

@ -626,6 +626,13 @@ export class BrotherPP1Service {
await this.sendCommand(Commands.MASK_TRACE, payload); 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> { async startSewing(): Promise<void> {
await this.sendCommand(Commands.START_SEWING); await this.sendCommand(Commands.START_SEWING);
} }

View file

@ -9,7 +9,11 @@ import type {
SewingProgress, SewingProgress,
} from "../types/machine"; } from "../types/machine";
import { MachineStatus, MachineStatusNames } 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 { uuidToString } from "../services/PatternCacheService";
import { createStorageService } from "../platform"; import { createStorageService } from "../platform";
import type { IStorageService } from "../platform/interfaces/IStorageService"; import type { IStorageService } from "../platform/interfaces/IStorageService";
@ -41,6 +45,11 @@ interface MachineState {
isCommunicating: boolean; isCommunicating: boolean;
isDeleting: 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 // Polling control
pollIntervalId: NodeJS.Timeout | null; pollIntervalId: NodeJS.Timeout | null;
serviceCountIntervalId: NodeJS.Timeout | null; serviceCountIntervalId: NodeJS.Timeout | null;
@ -56,6 +65,8 @@ interface MachineState {
startSewing: () => Promise<void>; startSewing: () => Promise<void>;
resumeSewing: () => Promise<void>; resumeSewing: () => Promise<void>;
deletePattern: () => Promise<void>; deletePattern: () => Promise<void>;
setStitchPosition: (index: number) => Promise<void>;
adjustStitchPosition: (offset: number) => Promise<void>;
// Initialization // Initialization
initialize: () => void; initialize: () => void;
@ -64,6 +75,7 @@ interface MachineState {
_setupSubscriptions: () => void; _setupSubscriptions: () => void;
_startPolling: () => void; _startPolling: () => void;
_stopPolling: () => void; _stopPolling: () => void;
_handleErrorStitchRollback: () => Promise<void>;
} }
export const useMachineStore = create<MachineState>((set, get) => ({ export const useMachineStore = create<MachineState>((set, get) => ({
@ -81,6 +93,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
isPairingError: false, isPairingError: false,
isCommunicating: false, isCommunicating: false,
isDeleting: false, isDeleting: false,
adjustedStitchIndex: null,
lastRolledBackError: null,
pausedStitchIndex: null,
pollIntervalId: null, pollIntervalId: null,
serviceCountIntervalId: null, serviceCountIntervalId: null,
@ -104,6 +119,14 @@ export const useMachineStore = create<MachineState>((set, get) => ({
machineError: state.error, 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 // Check for resume possibility using cache store
const { useMachineCacheStore } = await import("./useMachineCacheStore"); const { useMachineCacheStore } = await import("./useMachineCacheStore");
await useMachineCacheStore.getState().checkResume(); await useMachineCacheStore.getState().checkResume();
@ -237,7 +260,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
if (!isConnected) return; if (!isConnected) return;
try { try {
set({ error: null }); set({
error: null,
adjustedStitchIndex: null,
lastRolledBackError: null,
pausedStitchIndex: null,
});
await service.startSewing(); await service.startSewing();
await refreshStatus(); await refreshStatus();
} catch (err) { } catch (err) {
@ -253,7 +281,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
if (!isConnected) return; if (!isConnected) return;
try { try {
set({ error: null }); set({
error: null,
adjustedStitchIndex: null,
lastRolledBackError: null,
pausedStitchIndex: null,
});
await service.resumeSewing(); await service.resumeSewing();
await refreshStatus(); await refreshStatus();
} catch (err) { } catch (err) {
@ -304,6 +337,64 @@ export const useMachineStore = create<MachineState>((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 the store (call once from App component)
initialize: () => { initialize: () => {
get()._setupSubscriptions(); get()._setupSubscriptions();
@ -359,7 +450,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
status === MachineStatus.MASK_TRACING || status === MachineStatus.MASK_TRACING ||
status === MachineStatus.SEWING_DATA_RECEIVE status === MachineStatus.SEWING_DATA_RECEIVE
) { ) {
return 500; return 1000;
} else if ( } else if (
status === MachineStatus.COLOR_CHANGE_WAIT || status === MachineStatus.COLOR_CHANGE_WAIT ||
status === MachineStatus.MASK_TRACE_LOCK_WAIT || status === MachineStatus.MASK_TRACE_LOCK_WAIT ||
@ -374,11 +465,48 @@ export const useMachineStore = create<MachineState>((set, get) => ({
const poll = async () => { const poll = async () => {
await refreshStatus(); await refreshStatus();
const currentState = get();
const category = getMachineStateCategory(currentState.machineStatus);
// Refresh progress during sewing // Refresh progress during sewing
if (get().machineStatus === MachineStatus.SEWING) { if (currentState.machineStatus === MachineStatus.SEWING) {
await refreshProgress(); 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,
});
}
}
// Note: we intentionally do NOT clear lastRolledBackError when the error clears
// while still paused, so the rollback info text remains visible to the user.
// 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: // follows the apps logic:
// Check if we have a cached pattern and pattern info needs refreshing // Check if we have a cached pattern and pattern info needs refreshing
const { useMachineCacheStore } = await import("./useMachineCacheStore"); const { useMachineCacheStore } = await import("./useMachineCacheStore");
@ -434,6 +562,12 @@ export const usePatternInfo = () =>
useMachineStore((state) => state.patternInfo); useMachineStore((state) => state.patternInfo);
export const useSewingProgress = () => export const useSewingProgress = () =>
useMachineStore((state) => state.sewingProgress); 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 // Derived state: pattern is uploaded if machine has pattern info
export const usePatternUploaded = () => export const usePatternUploaded = () =>
useMachineStore((state) => state.patternInfo !== null); useMachineStore((state) => state.patternInfo !== null);

View file

@ -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 * Export ErrorInfo type for use in other files
*/ */

View file

@ -97,34 +97,67 @@ export function canUploadPattern(status: MachineStatus): boolean {
export function canStartSewing(status: MachineStatus): boolean { export function canStartSewing(status: MachineStatus): boolean {
// Only in specific ready states // Only in specific ready states
return ( return (
status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.MASK_TRACE_COMPLETE || status === MachineStatus.MASK_TRACE_COMPLETE ||
status === MachineStatus.PAUSE || status === MachineStatus.PAUSE ||
status === MachineStatus.STOP ||
status === MachineStatus.SEWING_INTERRUPTION status === MachineStatus.SEWING_INTERRUPTION
); );
} }
/** /**
* Determines if mask trace can be started in the current state. * 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 { export function canStartMaskTrace(
// Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace status: MachineStatus,
return ( hasSewingProgress = false,
): boolean {
if (
status === MachineStatus.IDLE || status === MachineStatus.IDLE ||
status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.MASK_TRACE_COMPLETE 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;
} }
/** /**
* Determines if sewing can be resumed in the current state. * Determines if sewing can be resumed in the current state.
* Only for interrupted operations (PAUSE, STOP, SEWING_INTERRUPTION). * Only for PAUSE and SEWING_INTERRUPTION - not STOP, which requires
* the user to resolve the error on the machine first.
*/ */
export function canResumeSewing(status: MachineStatus): boolean { export function canResumeSewing(status: MachineStatus): boolean {
// Only in interrupted states return (
const category = getMachineStateCategory(status); status === MachineStatus.PAUSE ||
return category === MachineStateCategory.INTERRUPTED; status === MachineStatus.SEWING_INTERRUPTION
);
}
/**
* 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;
} }
/** /**