diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 8b689d18d..8c78488b2 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -24,7 +24,7 @@ * `middleware/adapters/web/`. */ -import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' +import { deployReachedDevice, deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' import { probeRuntimeVersion } from '@root/backend/shared/library/probe-runtime-version' import { fromSchemaShape, @@ -387,7 +387,11 @@ export function createEditorCompilerPlatformPort( startIntervalMs: context.startIntervalMs, }) - return { ok: deployOutcome === 'STARTED' } + // 'STARTED' is not the only success: a runtime that refused to start + // because its hardware mode switch reads STOP has still taken the + // program. deployReachedDevice keeps that judgement in one place, shared + // with the web adapter. + return { ok: deployReachedDevice(deployOutcome) } } catch (error) { const message = error instanceof Error ? error.message : String(error) log(`Runtime v4 upload failed: ${message}`, 'error') @@ -486,7 +490,11 @@ export function createEditorCompilerPlatformPort( startTimeoutMs: context.startTimeoutMs, startIntervalMs: context.startIntervalMs, }) - return { ok: deployOutcome === 'STARTED' } + // 'STARTED' is not the only success: a runtime that refused to start + // because its hardware mode switch reads STOP has still taken the + // program. deployReachedDevice keeps that judgement in one place, shared + // with the web adapter. + return { ok: deployReachedDevice(deployOutcome) } } catch (error) { const message = error instanceof Error ? error.message : String(error) log(`Runtime v3 upload failed: ${message}`, 'error') diff --git a/src/backend/shared/library/__tests__/start-plc-after-build.test.ts b/src/backend/shared/library/__tests__/start-plc-after-build.test.ts index 15d97ab21..6829b160a 100644 --- a/src/backend/shared/library/__tests__/start-plc-after-build.test.ts +++ b/src/backend/shared/library/__tests__/start-plc-after-build.test.ts @@ -54,6 +54,25 @@ describe('startPlcAfterBuild', () => { expect(calls()).toBe(3) }) + it('treats a mode switch in STOP as a warning, not a failure', async () => { + // The runtime refuses to start while a hardware switch reads STOP. The + // program is on the device, so this must NOT read as a failed upload -- that + // sent users hunting for a problem that did not exist. + const { fetch, calls } = scriptedFetch(['START:ERROR_SWITCH_STOP']) + const logs: Array<{ level: string; message: string }> = [] + const outcome = await startPlcAfterBuild({ + fetchStart: fetch, + onLog: (level, message) => logs.push({ level, message }), + pollIntervalMs: 1, + }) + expect(outcome).toBe('SWITCH_IN_STOP') + // Not retried: nothing changes until a human moves the switch. + expect(calls()).toBe(1) + expect(logs[logs.length - 1].level).toBe('warning') + expect(logs[logs.length - 1].message).toMatch(/uploaded/i) + expect(logs[logs.length - 1].message).toMatch(/switch is in STOP/i) + }) + it('bails with FAILED on a non-BUSY error reply', async () => { const { fetch, calls } = scriptedFetch(['ERROR:INVALID_PROGRAM']) const logs: Array<{ level: string; message: string }> = [] diff --git a/src/backend/shared/library/deploy-runtime-program.ts b/src/backend/shared/library/deploy-runtime-program.ts index 783ae5676..2ae037970 100644 --- a/src/backend/shared/library/deploy-runtime-program.ts +++ b/src/backend/shared/library/deploy-runtime-program.ts @@ -23,6 +23,7 @@ import { startPlcAfterBuild, type StartPlcAfterBuildOptions } from './start-plc- export type DeployRuntimeProgramOutcome = | 'STARTED' + | 'UPLOADED_NOT_STARTED' | 'UPLOAD_FAILED' | 'BUILD_FAILED' | 'BUILD_TIMEOUT' @@ -30,6 +31,23 @@ export type DeployRuntimeProgramOutcome = | 'START_FAILED' | 'START_TIMEOUT' +/** + * Did the program reach the device? + * + * Distinct from "did it start", because those are different questions and the + * upload step must only fail for the first one. A runtime that refuses to start + * while its hardware mode switch reads STOP has still taken the program, and + * reporting that as a failed upload sends the user looking for a problem that + * does not exist. + * + * Shared rather than re-derived per platform: the editor and web adapters both + * turn this outcome into `UploadResult.ok`, and they must not drift on which + * outcomes count. + */ +export function deployReachedDevice(outcome: DeployRuntimeProgramOutcome): boolean { + return outcome === 'STARTED' || outcome === 'UPLOADED_NOT_STARTED' +} + export type DeployRuntimeProgramLogLevel = 'info' | 'error' | 'warning' | 'debug' export interface DeployRuntimeProgramOptions { @@ -109,6 +127,9 @@ export async function deployRuntimeProgram(opts: DeployRuntimeProgramOptions): P pollIntervalMs: opts.startIntervalMs, }) if (startOutcome === 'STARTED') return 'STARTED' + // Refused by the hardware mode switch: uploaded, deliberately not running. + // startPlcAfterBuild has already explained it as a warning. + if (startOutcome === 'SWITCH_IN_STOP') return 'UPLOADED_NOT_STARTED' if (startOutcome === 'TIMEOUT') return 'START_TIMEOUT' return 'START_FAILED' } diff --git a/src/backend/shared/library/start-plc-after-build.ts b/src/backend/shared/library/start-plc-after-build.ts index 583b60a51..fda248154 100644 --- a/src/backend/shared/library/start-plc-after-build.ts +++ b/src/backend/shared/library/start-plc-after-build.ts @@ -21,12 +21,17 @@ * - 150-ms interval between attempts. * - `START:OK` or `ALREADY_RUNNING` in the runtime's reply → SUCCESS. * - `BUSY` in the reply → keep retrying. + * - `ERROR_SWITCH_STOP` → SWITCH_IN_STOP: not an error. The runtime + * refuses to start while a hardware mode switch reads STOP, and + * leaving the switch there is a normal thing for someone to do + * while uploading. The upload succeeded; only the start was + * declined, and the user decides when to allow it. * - Any other reply → terminal failure (e.g. invalid program). * - Network error on the fetch → terminal failure. * - Deadline elapsed → TIMEOUT (a warning, not a fatal error). */ -export type StartPlcAfterBuildOutcome = 'STARTED' | 'FAILED' | 'TIMEOUT' +export type StartPlcAfterBuildOutcome = 'STARTED' | 'FAILED' | 'TIMEOUT' | 'SWITCH_IN_STOP' export type StartPlcAfterBuildLogLevel = 'info' | 'error' | 'warning' @@ -74,6 +79,18 @@ export async function startPlcAfterBuild(opts: StartPlcAfterBuildOptions): Promi return 'STARTED' } + // A hardware mode switch in STOP is a refusal, not a failure: the + // program is on the device, and the runtime is doing exactly what it + // should by declining to run it until the switch says so. Retrying + // would be wrong too — nothing changes until someone moves it. + if (status.includes('ERROR_SWITCH_STOP')) { + opts.onLog( + 'warning', + 'Program uploaded. The PLC was not started because the mode switch is in STOP — move it to RUN to start.', + ) + return 'SWITCH_IN_STOP' + } + // Only BUSY is retryable — everything else is a real error // from the runtime (invalid program, compile error, …). if (!status.includes('BUSY')) { diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index a11b96cda..acdab609a 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -425,6 +425,11 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const handlePlcControl = useCallback(async (): Promise => { if (!jwtToken || connectionStatus !== 'connected') return + // Mid-transition the runtime refuses everything but PING and STATUS with + // COMMAND:BUSY, so a start or stop here can only produce a confusing error. + // The button is disabled for this too; guarding the handler as well covers + // any other caller and the gap between a stale render and the next poll. + if (plcStatus === 'TRANSITIONING') return try { if (plcStatus === 'RUNNING') { @@ -460,6 +465,18 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } }, [runtime, jwtToken, connectionStatus, plcStatus, addLog]) + /** + * Run/stop is blocked while the runtime is mid-transition. + * + * TRANSITIONING means a start or stop is in flight: the runtime answers + * COMMAND:BUSY to everything except PING and STATUS, and the state it will + * settle on is not decided yet. Clicking then cannot do what the icon says -- + * the icon itself is ambiguous, since it is drawn from a state that is about to + * change -- so the button goes inert until the runtime lands. + */ + const isPlcTransitioning = plcStatus === 'TRANSITIONING' + const isPlcControlBlocked = connectionStatus !== 'connected' || isPlcTransitioning + // --------------------------------------------------------------------------- // Simulator control (Start/Stop simulator + auto-debug) // --------------------------------------------------------------------------- @@ -904,20 +921,22 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa : 'Start Simulator' : connectionStatus !== 'connected' ? 'Connect to runtime first' - : plcStatus === 'RUNNING' - ? 'Stop PLC' - : 'Start PLC' + : isPlcTransitioning + ? 'PLC is changing state...' + : plcStatus === 'RUNNING' + ? 'Stop PLC' + : 'Start PLC' } > void handleSimulatorControl() : () => void handlePlcControl()} - disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : connectionStatus !== 'connected'} + disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : isPlcControlBlocked} className={cn( isSimulatorBoard ? isCompiling || isDebuggerProcessing ? disabledButtonClass : '' - : connectionStatus !== 'connected' + : isPlcControlBlocked ? disabledButtonClass : '', )}