From 130585a8d146db3adc39511752daac0a805059ad Mon Sep 17 00:00:00 2001 From: Emerson Lopes <24904209+emerson-d-lopes@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:03:19 -0300 Subject: [PATCH 01/79] fix(pou): show return type selector for graphical function POUs The return type selector in the variables editor was gated on editor.type === 'plc-textual', so functions written in FBD, LD, or SFC had no way to select their return type even though the store action, POU creation defaults, and the ST transpiler already fully support return types for graphical functions. Extend the condition to include 'plc-graphical'. Add a component test covering the selector's visibility for FBD/LD/ST functions and its absence for programs and function blocks. Fixes #696 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U5TQ1y3yBEDx73tYTHoUBj --- .../__tests__/return-type-visibility.test.tsx | 47 +++++++++++++++++++ .../_organisms/variables-editor/index.tsx | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/frontend/components/_organisms/variables-editor/__tests__/return-type-visibility.test.tsx diff --git a/src/frontend/components/_organisms/variables-editor/__tests__/return-type-visibility.test.tsx b/src/frontend/components/_organisms/variables-editor/__tests__/return-type-visibility.test.tsx new file mode 100644 index 000000000..f0bdda07a --- /dev/null +++ b/src/frontend/components/_organisms/variables-editor/__tests__/return-type-visibility.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react' + +// The variables code editor pulls in Monaco, which cannot run in jsdom. +vi.mock('@root/frontend/components/_organisms/variables-code-editor', () => ({ + VariablesCodeEditor: () =>
, +})) + +import { useOpenPLCStore } from '../../../../store' +import { VariablesEditor } from '../index' + +const createPou = (name: string, type: 'program' | 'function' | 'function-block', language: 'st' | 'fbd' | 'ld') => { + const result = useOpenPLCStore.getState().pouActions.create({ type, name, language }) + expect(result.ok).toBe(true) +} + +// https://github.com/Autonomy-Logic/openplc-editor/issues/696 +describe('VariablesEditor return type selector', () => { + it('shows the return type selector for a function written in FBD', () => { + createPou('FbdFunction', 'function', 'fbd') + render() + expect(screen.getByText('Return type :')).toBeTruthy() + }) + + it('shows the return type selector for a function written in LD', () => { + createPou('LdFunction', 'function', 'ld') + render() + expect(screen.getByText('Return type :')).toBeTruthy() + }) + + it('shows the return type selector for a function written in ST', () => { + createPou('StFunction', 'function', 'st') + render() + expect(screen.getByText('Return type :')).toBeTruthy() + }) + + it('does not show the return type selector for an FBD program', () => { + createPou('FbdProgram', 'program', 'fbd') + render() + expect(screen.queryByText('Return type :')).toBeNull() + }) + + it('does not show the return type selector for an FBD function block', () => { + createPou('FbdBlock', 'function-block', 'fbd') + render() + expect(screen.queryByText('Return type :')).toBeNull() + }) +}) diff --git a/src/frontend/components/_organisms/variables-editor/index.tsx b/src/frontend/components/_organisms/variables-editor/index.tsx index e3b650e59..582cb7ecd 100644 --- a/src/frontend/components/_organisms/variables-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-editor/index.tsx @@ -1052,7 +1052,7 @@ const VariablesEditor = ({ name: propName, isActive: _isActive = true }: Variabl
{editorVariables.display === 'table' && (
- {editor.type === 'plc-textual' && editor.meta.pouType === 'function' && ( + {(editor.type === 'plc-textual' || editor.type === 'plc-graphical') && editor.meta.pouType === 'function' && (
+ +
+ )}
Date: Wed, 29 Jul 2026 15:18:59 -0300 Subject: [PATCH 07/79] Merge pull request #970 from Autonomy-Logic/chore/node-137-debug-poll-interval fix(debug): expose HTTP fallback poll interval as a platform capability --- src/frontend/hooks/useDebugPolling.ts | 10 ++++------ .../shared/ports/platform-capabilities.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index 08f263b81..7805f45cf 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -19,7 +19,8 @@ * * Polling intervals: * - Modbus RTU / simulator: 50ms (no network; keep the UI snappy) - * - Web HTTP fallback: 1000ms (WebRTC failed; each poll is a slow + * - Web HTTP fallback: platform-capability-driven, 1000ms by default + * (WebRTC failed; each poll is a slow * orchestrator round-trip, so back off) * - Everything else: 200ms (general purpose — TCP / WebSocket / * web WebRTC data channel) @@ -38,10 +39,6 @@ import { getTypeSizeByName, parseValueByTypeName } from '../utils/variable-sizes const RTU_POLL_INTERVAL_MS = 50 /** Polling interval for higher-bandwidth transports (TCP / WebSocket). */ const DEFAULT_POLL_INTERVAL_MS = 200 -/** Polling interval for the web HTTP fallback (WebRTC unavailable): each - * poll is a full orchestrator `run_command` round-trip, so we slow right - * down to avoid hammering the edge API / runtime. */ -const HTTP_FALLBACK_POLL_INTERVAL_MS = 1000 // Batch size is transport-dependent. The wire request packs 3 bytes per // variable (arr:u8 + elem:u16); the response packs raw type-sized values @@ -434,7 +431,7 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const pollIntervalMs = usesRtuFraming ? RTU_POLL_INTERVAL_MS : usesHttpFallback - ? HTTP_FALLBACK_POLL_INTERVAL_MS + ? capabilities.debugHttpFallbackPollIntervalMs : DEFAULT_POLL_INTERVAL_MS // Fire first poll immediately, then schedule at fixed rate @@ -492,6 +489,7 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void debugConnectionType, sessionDebugTransport, capabilities.isNativeApplication, + capabilities.debugHttpFallbackPollIntervalMs, workspaceActions, ]) diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index 95bd82bd4..235ddc340 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -104,6 +104,17 @@ export interface PlatformCapabilities { /** True if the app supports EtherCAT device configuration and ESI repository. */ hasEthercat: boolean + // --- Debugging --- + + /** + * Polling interval (ms) for the debugger's HTTP fallback transport (used + * when WebRTC is unavailable). Each poll is a full proxied round-trip, so + * this is deployment-tunable rather than a fixed constant — e.g. + * autonomy-node runs no WebRTC signaling relay and wants this to match + * its general-purpose poll rate. + */ + debugHttpFallbackPollIntervalMs: number + // --- Environment --- /** True when running in a development build (Vite DEV / webpack development mode). */ @@ -139,6 +150,7 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasDirectProgramUpload: false, hasPackageManager: true, hasEthercat: true, + debugHttpFallbackPollIntervalMs: 1000, isDevMode: false, } @@ -182,5 +194,6 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasDirectProgramUpload: true, hasPackageManager: false, hasEthercat: false, + debugHttpFallbackPollIntervalMs: 1000, isDevMode: false, } From d162fcb1ce653a2c9cec774518d27273926f8fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 30 Jul 2026 09:01:22 -0300 Subject: [PATCH 08/79] fix(debugger): support forcing TIME values (DOPE-331, #634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forcing a TIME variable did nothing at all — no value written, no error shown. `getVariableTypeInfo` (variable-types.ts) had no `time` case, so it returned null and all three force UIs bailed out on that null before building a buffer, silently closing the modal. The same gate blocked DT / DATE / TOD / LTIME / WSTRING and every enum-typed variable. TIME encoding: New `parseDurationLiteral` (utils/iec-duration.ts) — the inverse of `formatTimeValue`. Accepts T# / TIME# / LT# / LTIME# or no prefix at all (the watch panel renders durations prefix-less, so what it shows can be typed straight back in), a sign on either side of the prefix, `_` digit separators, unit chains (`1h30m`), overflow units (`90s`), and a fraction on the smallest unit (`1.5s`, round half away from zero). Result is range-checked against int64. `encodeByWireFormat` encodes `duration-ns-i64` as int64 LE nanoseconds — the shape strucpp already reads back. No runtime or firmware change: 8-byte forces (LINT/ULINT/LREAL) already work, and the endianness swap layer is type-generic. Single force encoder: The watch panel and the ladder / FBD variable nodes each carried their own copy of a getVariableTypeInfo + parse/buffer dispatch. All three now call `encodeForceValue`, which was already the canonical encoder (driven by strucpp's iec-types registry) but had no live caller. Errors surface as a toast instead of a silent modal close. STRING encoding moved into the encoder so the unified path keeps working; the panel also forwards `enumValues`, so forcing an enum by member name works there for the first time. DATE / TOD / DT / WSTRING still lack an encoder (they need calendar literal parsing / UTF-16 framing) but now say so out loud. Removed utils/variable-types.ts: every export was dead once the three components shared one encoder. Behaviour notes: - STRING input is trimmed, so leading/trailing spaces need IEC quoting (`' hi '`); quotes are unwrapped. - Forcing BOOL `0` / `FALSE` from the modal now renders forced-low (blue) instead of green, matching the Force False menu action, and the modal accepts the TRUE / FALSE keywords. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Btq4UkctubeNQvh2cYAUv --- .../_atoms/graphical-editor/fbd/variable.tsx | 60 +-- .../graphical-editor/ladder/variable.tsx | 67 +--- .../_molecules/variables-panel/index.tsx | 62 +-- .../utils/__tests__/iec-duration.test.ts | 100 +++++ .../utils/__tests__/variable-sizes.test.ts | 61 ++- .../utils/__tests__/variable-types.test.ts | 357 ------------------ src/frontend/utils/endian.ts | 2 +- src/frontend/utils/iec-duration.ts | 97 +++++ src/frontend/utils/variable-sizes.ts | 53 ++- src/frontend/utils/variable-types.ts | 162 -------- 10 files changed, 351 insertions(+), 670 deletions(-) create mode 100644 src/frontend/utils/__tests__/iec-duration.test.ts delete mode 100644 src/frontend/utils/__tests__/variable-types.test.ts create mode 100644 src/frontend/utils/iec-duration.ts delete mode 100644 src/frontend/utils/variable-types.ts diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx index 3f1a02777..86a68985e 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx @@ -10,15 +10,8 @@ import { resolveScopeExpressionType } from '../../../../services/graphical-scope import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' import { resolveArrayVariableByName } from '../../../../utils/PLC/array-variable-utils' -import { - floatToBuffer, - getVariableTypeInfo, - integerToBuffer, - parseFloatValue, - parseIntegerValue, - parseStringValue, - stringToBuffer, -} from '../../../../utils/variable-types' +import { encodeForceValue, isForcedValueHigh } from '../../../../utils/variable-sizes' +import { toast } from '../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { Modal, ModalContent, ModalTitle } from '../../../_molecules/modal' import { HighlightedTextArea } from '../../highlighted-textarea' @@ -338,50 +331,21 @@ const VariableElement = (block: VariableProps) => { return } - const typeInfo = getVariableTypeInfo(varType) - if (!typeInfo) { + let valueBuffer: Uint8Array + try { + valueBuffer = encodeForceValue(forceValue, varType) + } catch (error) { + toast({ + title: 'Cannot force value', + description: error instanceof Error ? error.message : String(error), + variant: 'fail', + }) setForceValueModalOpen(false) setForceValue('') return } - const normalizedType = varType.toLowerCase() - const isFloatType = normalizedType === 'real' || normalizedType === 'lreal' - const isStringType = normalizedType === 'string' - - let valueBuffer: Uint8Array - let forcedValueForState: boolean - - if (isStringType) { - const parsedStringValue: string | null = parseStringValue(forceValue) - if (parsedStringValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = stringToBuffer(parsedStringValue) - forcedValueForState = true - } else if (isFloatType) { - const parsedFloatValue = parseFloatValue(forceValue, typeInfo.byteSize) - if (parsedFloatValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = floatToBuffer(parsedFloatValue, typeInfo.byteSize) - forcedValueForState = parsedFloatValue >= 0 - } else { - const parsedIntValue = parseIntegerValue(forceValue, typeInfo) - if (parsedIntValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = integerToBuffer(parsedIntValue, typeInfo.byteSize, typeInfo.signed) - forcedValueForState = parsedIntValue >= BigInt(0) - } - - await forceDebugVariable(debugger_, compositeKey, debugIndex, valueBuffer, forcedValueForState, varType) + await forceDebugVariable(debugger_, compositeKey, debugIndex, valueBuffer, isForcedValueHigh(forceValue), varType) setForceValueModalOpen(false) setForceValue('') diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx index 9cbaf03a7..70bb1d726 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx @@ -11,15 +11,8 @@ import { useOpenPLCStore } from '../../../../store' import { RungLadderState } from '../../../../store/slices/ladder' import { cn } from '../../../../utils/cn' import { getLiteralType } from '../../../../utils/keywords' -import { - floatToBuffer, - getVariableTypeInfo, - integerToBuffer, - parseFloatValue, - parseIntegerValue, - parseStringValue, - stringToBuffer, -} from '../../../../utils/variable-types' +import { encodeForceValue, isForcedValueHigh } from '../../../../utils/variable-sizes' +import { toast } from '../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { Modal, ModalContent, ModalTitle } from '../../../_molecules/modal' import { HighlightedTextArea } from '../../highlighted-textarea' @@ -290,50 +283,28 @@ const VariableElement = (block: VariableProps) => { return } - const typeInfo = getVariableTypeInfo(variableType) - if (!typeInfo) { + let valueBuffer: Uint8Array + try { + valueBuffer = encodeForceValue(forceValue, variableType) + } catch (error) { + toast({ + title: 'Cannot force value', + description: error instanceof Error ? error.message : String(error), + variant: 'fail', + }) setForceValueModalOpen(false) setForceValue('') return } - const normalizedType = variableType.toLowerCase() - const isFloatType = normalizedType === 'real' || normalizedType === 'lreal' - const isStringType = normalizedType === 'string' - - let valueBuffer: Uint8Array - let forcedValueForState: boolean - - if (isStringType) { - const parsedStringValue: string | null = parseStringValue(forceValue) - if (parsedStringValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = stringToBuffer(parsedStringValue) - forcedValueForState = true - } else if (isFloatType) { - const parsedFloatValue = parseFloatValue(forceValue, typeInfo.byteSize) - if (parsedFloatValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = floatToBuffer(parsedFloatValue, typeInfo.byteSize) - forcedValueForState = parsedFloatValue >= 0 - } else { - const parsedIntValue = parseIntegerValue(forceValue, typeInfo) - if (parsedIntValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = integerToBuffer(parsedIntValue, typeInfo.byteSize, typeInfo.signed) - forcedValueForState = parsedIntValue >= BigInt(0) - } - - await forceDebugVariable(debugger_, compositeKey, debugIndex, valueBuffer, forcedValueForState, variableType) + await forceDebugVariable( + debugger_, + compositeKey, + debugIndex, + valueBuffer, + isForcedValueHigh(forceValue), + variableType, + ) setForceValueModalOpen(false) setForceValue('') diff --git a/src/frontend/components/_molecules/variables-panel/index.tsx b/src/frontend/components/_molecules/variables-panel/index.tsx index 180781057..dff7b39f5 100644 --- a/src/frontend/components/_molecules/variables-panel/index.tsx +++ b/src/frontend/components/_molecules/variables-panel/index.tsx @@ -5,17 +5,10 @@ import type { DebugTreeNode } from '../../../../middleware/shared/ports/types' import ViewIcon from '../../../assets/icons/interface/View' import ZapIcon from '../../../assets/icons/interface/Zap' import { cn } from '../../../utils/cn' -import { - floatToBuffer, - getVariableTypeInfo, - integerToBuffer, - parseFloatValue, - parseIntegerValue, - parseStringValue, - stringToBuffer, -} from '../../../utils/variable-types' +import { encodeForceValue, isForcedValueHigh } from '../../../utils/variable-sizes' import { TreeNode } from '../../_atoms/debug-tree-node' import { Label } from '../../_atoms/label' +import { toast } from '../../_features/[app]/toast/use-toast' import { Modal, ModalContent, ModalTitle } from '../modal' type Variable = { @@ -117,6 +110,7 @@ const VariablesPanel = ({ compositeKey: string lookupKey: string variableType: string + enumValues?: string[] position: { x: number; y: number } } | null>(null) const [forceValueModalOpen, setForceValueModalOpen] = useState(false) @@ -125,6 +119,7 @@ const VariablesPanel = ({ compositeKey: string lookupKey: string variableType: string + enumValues?: string[] } | null>(null) const getValue = (compositeKey: string): string | undefined => { @@ -199,6 +194,7 @@ const VariablesPanel = ({ compositeKey: node.compositeKey, lookupKey, variableType: node.type, + enumValues: node.enumValues, position, }) }, @@ -260,6 +256,7 @@ const VariablesPanel = ({ compositeKey: contextMenuState.compositeKey, lookupKey: contextMenuState.lookupKey, variableType: contextMenuState.variableType, + enumValues: contextMenuState.enumValues, }) } handleCloseContextMenu() @@ -281,49 +278,24 @@ const VariablesPanel = ({ } const variableType = pendingForceContext.variableType - const typeInfo = getVariableTypeInfo(variableType) - if (!typeInfo) { - closeModal() - return - } - - const normalizedType = variableType.toLowerCase() - const isFloatType = normalizedType === 'real' || normalizedType === 'lreal' - const isStringType = normalizedType === 'string' let valueBuffer: Uint8Array - let forcedValueForState: boolean - - if (isStringType) { - const parsedStringValue: string | null = parseStringValue(forceValue) - if (parsedStringValue === null) { - closeModal() - return - } - valueBuffer = stringToBuffer(parsedStringValue) - forcedValueForState = true - } else if (isFloatType) { - const parsedFloatValue = parseFloatValue(forceValue, typeInfo.byteSize) - if (parsedFloatValue === null) { - closeModal() - return - } - valueBuffer = floatToBuffer(parsedFloatValue, typeInfo.byteSize) - forcedValueForState = parsedFloatValue >= 0 - } else { - const parsedIntValue = parseIntegerValue(forceValue, typeInfo) - if (parsedIntValue === null) { - closeModal() - return - } - valueBuffer = integerToBuffer(parsedIntValue, typeInfo.byteSize, typeInfo.signed) - forcedValueForState = parsedIntValue >= BigInt(0) + try { + valueBuffer = encodeForceValue(forceValue, variableType, pendingForceContext.enumValues) + } catch (error) { + toast({ + title: 'Cannot force value', + description: error instanceof Error ? error.message : String(error), + variant: 'fail', + }) + closeModal() + return } void onForceVariable( pendingForceContext.compositeKey, variableType, - forcedValueForState, + isForcedValueHigh(forceValue), valueBuffer, pendingForceContext.lookupKey, ) diff --git a/src/frontend/utils/__tests__/iec-duration.test.ts b/src/frontend/utils/__tests__/iec-duration.test.ts new file mode 100644 index 000000000..4827d8223 --- /dev/null +++ b/src/frontend/utils/__tests__/iec-duration.test.ts @@ -0,0 +1,100 @@ +import { parseDurationLiteral } from '../iec-duration' + +const NS_PER_MS = 1_000_000n +const NS_PER_SEC = 1_000_000_000n +const NS_PER_MIN = 60n * NS_PER_SEC +const NS_PER_HOUR = 60n * NS_PER_MIN +const NS_PER_DAY = 24n * NS_PER_HOUR + +describe('parseDurationLiteral', () => { + it('parses a single component with every unit', () => { + expect(parseDurationLiteral('2d')).toBe(2n * NS_PER_DAY) + expect(parseDurationLiteral('3h')).toBe(3n * NS_PER_HOUR) + expect(parseDurationLiteral('4m')).toBe(4n * NS_PER_MIN) + expect(parseDurationLiteral('10s')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('250ms')).toBe(250n * NS_PER_MS) + expect(parseDurationLiteral('7us')).toBe(7_000n) + expect(parseDurationLiteral('9ns')).toBe(9n) + }) + + it('accepts the T# / TIME# / LT# / LTIME# prefixes, case-insensitively', () => { + expect(parseDurationLiteral('T#10s')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('t#10s')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('TIME#10S')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('LT#10s')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('ltime#10s')).toBe(10n * NS_PER_SEC) + }) + + it('sums a chain of descending units', () => { + expect(parseDurationLiteral('1h30m')).toBe(NS_PER_HOUR + 30n * NS_PER_MIN) + expect(parseDurationLiteral('T#1d2h3m4s5ms6us7ns')).toBe( + NS_PER_DAY + 2n * NS_PER_HOUR + 3n * NS_PER_MIN + 4n * NS_PER_SEC + 5n * NS_PER_MS + 6_000n + 7n, + ) + }) + + it('round-trips what the watch panel renders', () => { + // formatTimeValue emits prefix-less, at most two components. + expect(parseDurationLiteral('3s800ms')).toBe(3n * NS_PER_SEC + 800n * NS_PER_MS) + expect(parseDurationLiteral('2d3h')).toBe(2n * NS_PER_DAY + 3n * NS_PER_HOUR) + expect(parseDurationLiteral('0s')).toBe(0n) + expect(parseDurationLiteral('-5s')).toBe(-5n * NS_PER_SEC) + }) + + it('allows overflow units (90s is not clamped to a minute)', () => { + expect(parseDurationLiteral('90s')).toBe(90n * NS_PER_SEC) + expect(parseDurationLiteral('T#5000ms')).toBe(5n * NS_PER_SEC) + }) + + it('accepts a sign on either side of the prefix', () => { + expect(parseDurationLiteral('T#-5s')).toBe(-5n * NS_PER_SEC) + expect(parseDurationLiteral('-T#5s')).toBe(-5n * NS_PER_SEC) + expect(parseDurationLiteral('+T#5s')).toBe(5n * NS_PER_SEC) + expect(parseDurationLiteral('+5s')).toBe(5n * NS_PER_SEC) + // Sign on both sides cancels out. + expect(parseDurationLiteral('-T#-5s')).toBe(5n * NS_PER_SEC) + }) + + it('ignores underscore digit separators and internal whitespace', () => { + expect(parseDurationLiteral('T#1_000ms')).toBe(NS_PER_SEC) + expect(parseDurationLiteral('T#1d 2h')).toBe(NS_PER_DAY + 2n * NS_PER_HOUR) + }) + + it('accepts a fraction on the smallest unit', () => { + expect(parseDurationLiteral('1.5s')).toBe(1_500n * NS_PER_MS) + expect(parseDurationLiteral('T#1m0.25s')).toBe(NS_PER_MIN + 250n * NS_PER_MS) + // Sub-nanosecond fractions round half away from zero. + expect(parseDurationLiteral('0.0000000005s')).toBe(1n) + expect(parseDurationLiteral('0.0000000004s')).toBe(0n) + expect(parseDurationLiteral('-1.5s')).toBe(-1_500n * NS_PER_MS) + }) + + it('rejects an empty or unit-less value', () => { + expect(() => parseDurationLiteral('')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral(' ')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral('T#')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral('10')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral('abc')).toThrow(/Invalid TIME value/) + }) + + it('rejects trailing junk after a valid component', () => { + expect(() => parseDurationLiteral('10s!')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral('10sec')).toThrow(/Invalid TIME value/) + }) + + it('rejects repeated or ascending units', () => { + expect(() => parseDurationLiteral('1s1s')).toThrow(/largest first/) + expect(() => parseDurationLiteral('30m1h')).toThrow(/largest first/) + expect(() => parseDurationLiteral('500ms1s')).toThrow(/largest first/) + }) + + it('rejects a fraction on anything but the smallest unit', () => { + expect(() => parseDurationLiteral('1.5h30m')).toThrow(/smallest unit/) + }) + + it('rejects values outside the int64 nanosecond range', () => { + expect(() => parseDurationLiteral('106752d')).toThrow(/out of range/) + expect(() => parseDurationLiteral('-106752d')).toThrow(/out of range/) + // Just inside the range still parses. + expect(parseDurationLiteral('106751d')).toBe(106751n * NS_PER_DAY) + }) +}) diff --git a/src/frontend/utils/__tests__/variable-sizes.test.ts b/src/frontend/utils/__tests__/variable-sizes.test.ts index 0b281bba0..eec990b9e 100644 --- a/src/frontend/utils/__tests__/variable-sizes.test.ts +++ b/src/frontend/utils/__tests__/variable-sizes.test.ts @@ -3,6 +3,7 @@ import { encodeForceValue, getTypeSizeByName, getVariableSize, + isForcedValueHigh, parseValueByTypeName, parseVariableValue, } from '../variable-sizes' @@ -476,6 +477,11 @@ describe('parseValueByTypeName', () => { const result = parseValueByTypeName(data, 0, 'usint') expect(result).toEqual({ value: '42', bytesRead: 1 }) }) + + it('renders an unknown type as ??? and consumes the 4-byte default', () => { + const result = parseValueByTypeName(u8(1, 2, 3, 4), 0, 'TOTALLY_FAKE_TYPE') + expect(result).toEqual({ value: '???', bytesRead: 4 }) + }) }) // --------------------------------------------------------------------------- @@ -510,9 +516,36 @@ describe('encodeForceValue', () => { ) }) - it('rejects unsupported types cleanly', () => { - expect(() => encodeForceValue('5s', 'TIME')).toThrow(/not supported/) - expect(() => encodeForceValue('"hello"', 'STRING')).toThrow(/not supported/) + it('encodes TIME as int64 nanoseconds, little-endian', () => { + // T#10s → 10e9 ns → 0x00000002540BE400 + expect(Array.from(encodeForceValue('T#10s', 'TIME'))).toEqual([0x00, 0xe4, 0x0b, 0x54, 0x02, 0x00, 0x00, 0x00]) + // Prefix-less input (what the watch panel renders) works too. + expect(Array.from(encodeForceValue('250ms', 'TIME'))).toEqual([0x80, 0xb2, 0xe6, 0x0e, 0x00, 0x00, 0x00, 0x00]) + // Negative durations are two-complement. + expect(Array.from(encodeForceValue('T#-1ns', 'TIME'))).toEqual([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) + }) + + it('surfaces the duration parser error for a malformed TIME value', () => { + expect(() => encodeForceValue('10', 'TIME')).toThrow(/Invalid TIME value/) + }) + + it('encodes STRING as a length byte followed by ASCII', () => { + expect(Array.from(encodeForceValue('hi', 'STRING'))).toEqual([2, 0x68, 0x69]) + // IEC literal quotes are unwrapped — and are how spaces survive trimming. + expect(Array.from(encodeForceValue("' hi '", 'STRING'))).toEqual([4, 0x20, 0x68, 0x69, 0x20]) + expect(Array.from(encodeForceValue("''", 'STRING'))).toEqual([0]) + }) + + it('rejects STRING values that are non-ASCII or over the protocol cap', () => { + expect(() => encodeForceValue('café', 'STRING')).toThrow(/must be ASCII/) + expect(() => encodeForceValue('x'.repeat(127), 'STRING')).toThrow(/too long: 127 characters \(max 126\)/) + }) + + it('rejects the wire formats that still lack an encoder', () => { + expect(() => encodeForceValue('D#2026-01-01', 'DATE')).toThrow(/not supported/) + expect(() => encodeForceValue('TOD#12:00:00', 'TOD')).toThrow(/not supported/) + expect(() => encodeForceValue('DT#2026-01-01-12:00:00', 'DT')).toThrow(/not supported/) + expect(() => encodeForceValue('"hello"', 'WSTRING')).toThrow(/not supported/) }) describe('out-of-range truncation', () => { @@ -630,3 +663,25 @@ describe('encodeForceValue', () => { }) }) }) + +// --------------------------------------------------------------------------- +// isForcedValueHigh +// --------------------------------------------------------------------------- + +describe('isForcedValueHigh', () => { + it('treats FALSE / 0 / negative values as forced-low', () => { + expect(isForcedValueHigh('FALSE')).toBe(false) + expect(isForcedValueHigh(' false ')).toBe(false) + expect(isForcedValueHigh('0')).toBe(false) + expect(isForcedValueHigh('-1')).toBe(false) + expect(isForcedValueHigh('T#-5s')).toBe(false) + }) + + it('treats everything else as forced-high', () => { + expect(isForcedValueHigh('TRUE')).toBe(true) + expect(isForcedValueHigh('1')).toBe(true) + expect(isForcedValueHigh('T#10s')).toBe(true) + expect(isForcedValueHigh('0s')).toBe(true) + expect(isForcedValueHigh('hello')).toBe(true) + }) +}) diff --git a/src/frontend/utils/__tests__/variable-types.test.ts b/src/frontend/utils/__tests__/variable-types.test.ts deleted file mode 100644 index 88333fbbc..000000000 --- a/src/frontend/utils/__tests__/variable-types.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import type { VariableTypeInfo } from '../variable-types' -import { - floatToBuffer, - getVariableTypeInfo, - integerToBuffer, - parseFloatValue, - parseIntegerValue, - parseStringValue, - stringToBuffer, -} from '../variable-types' - -// --------------------------------------------------------------------------- -// getVariableTypeInfo -// --------------------------------------------------------------------------- - -describe('getVariableTypeInfo', () => { - it.each([ - ['BOOL', { byteSize: 1, signed: false }], - ['SINT', { byteSize: 1, signed: true }], - ['USINT', { byteSize: 1, signed: false }], - ['BYTE', { byteSize: 1, signed: false }], - ['INT', { byteSize: 2, signed: true }], - ['UINT', { byteSize: 2, signed: false }], - ['WORD', { byteSize: 2, signed: false }], - ['DINT', { byteSize: 4, signed: true }], - ['UDINT', { byteSize: 4, signed: false }], - ['DWORD', { byteSize: 4, signed: false }], - ['LINT', { byteSize: 8, signed: true }], - ['ULINT', { byteSize: 8, signed: false }], - ['LWORD', { byteSize: 8, signed: false }], - ['REAL', { byteSize: 4, signed: true }], - ['LREAL', { byteSize: 8, signed: true }], - ['STRING', { byteSize: 127, signed: false }], - ])('returns correct info for %s', (type, expected) => { - expect(getVariableTypeInfo(type)).toEqual(expected) - }) - - it('is case-insensitive', () => { - expect(getVariableTypeInfo('bool')).toEqual({ byteSize: 1, signed: false }) - expect(getVariableTypeInfo('Bool')).toEqual({ byteSize: 1, signed: false }) - }) - - it('returns null for unknown type', () => { - expect(getVariableTypeInfo('WSTRING')).toBeNull() - expect(getVariableTypeInfo('TIME')).toBeNull() - }) -}) - -// --------------------------------------------------------------------------- -// parseIntegerValue -// --------------------------------------------------------------------------- - -describe('parseIntegerValue', () => { - const sint: VariableTypeInfo = { byteSize: 1, signed: true } - const usint: VariableTypeInfo = { byteSize: 1, signed: false } - const int16: VariableTypeInfo = { byteSize: 2, signed: true } - const uint16: VariableTypeInfo = { byteSize: 2, signed: false } - const dint: VariableTypeInfo = { byteSize: 4, signed: true } - - it('parses decimal string', () => { - expect(parseIntegerValue('42', usint)).toBe(42n) - }) - - it('parses negative decimal for signed type', () => { - expect(parseIntegerValue('-100', sint)).toBe(-100n) - }) - - it('parses 0x hex prefix', () => { - expect(parseIntegerValue('0xFF', usint)).toBe(255n) - }) - - it('parses 0X hex prefix (uppercase)', () => { - expect(parseIntegerValue('0XFF', usint)).toBe(255n) - }) - - it('parses IEC 16# hex prefix', () => { - expect(parseIntegerValue('16#FF', usint)).toBe(255n) - }) - - it('parses IEC 2# binary prefix', () => { - expect(parseIntegerValue('2#11111111', usint)).toBe(255n) - }) - - it('parses IEC 8# octal prefix', () => { - expect(parseIntegerValue('8#377', usint)).toBe(255n) - }) - - it('trims whitespace', () => { - expect(parseIntegerValue(' 42 ', usint)).toBe(42n) - }) - - it('returns null when value exceeds max for signed type', () => { - // SINT max = 127 - expect(parseIntegerValue('128', sint)).toBeNull() - }) - - it('returns null when value is below min for signed type', () => { - // SINT min = -128 - expect(parseIntegerValue('-129', sint)).toBeNull() - }) - - it('returns null when unsigned value is negative', () => { - expect(parseIntegerValue('-1', usint)).toBeNull() - }) - - it('returns null when value exceeds max for unsigned type', () => { - // USINT max = 255 - expect(parseIntegerValue('256', usint)).toBeNull() - }) - - it('returns null for non-numeric input', () => { - expect(parseIntegerValue('abc', sint)).toBeNull() - }) - - it('treats empty string as 0', () => { - // BigInt('') returns 0n, so empty string parses to 0 - expect(parseIntegerValue('', sint)).toBe(0n) - }) - - it('handles INT range correctly', () => { - expect(parseIntegerValue('32767', int16)).toBe(32767n) - expect(parseIntegerValue('-32768', int16)).toBe(-32768n) - expect(parseIntegerValue('32768', int16)).toBeNull() - }) - - it('handles UINT range correctly', () => { - expect(parseIntegerValue('65535', uint16)).toBe(65535n) - expect(parseIntegerValue('65536', uint16)).toBeNull() - }) - - it('handles DINT range', () => { - expect(parseIntegerValue('2147483647', dint)).toBe(2147483647n) - expect(parseIntegerValue('-2147483648', dint)).toBe(-2147483648n) - expect(parseIntegerValue('2147483648', dint)).toBeNull() - }) -}) - -// --------------------------------------------------------------------------- -// integerToBuffer -// --------------------------------------------------------------------------- - -describe('integerToBuffer', () => { - it('converts positive unsigned value to buffer', () => { - const result = integerToBuffer(255n, 1, false) - expect(result).toEqual(new Uint8Array([0xff])) - }) - - it('converts positive signed value to buffer', () => { - const result = integerToBuffer(127n, 1, true) - expect(result).toEqual(new Uint8Array([0x7f])) - }) - - it('converts negative signed value using twos complement', () => { - // -1 in 1-byte signed = 0xFF - const result = integerToBuffer(-1n, 1, true) - expect(result).toEqual(new Uint8Array([0xff])) - }) - - it('converts negative signed 2-byte value', () => { - // -1 in 2-byte signed = 0xFF 0xFF - const result = integerToBuffer(-1n, 2, true) - expect(result).toEqual(new Uint8Array([0xff, 0xff])) - }) - - it('converts zero', () => { - const result = integerToBuffer(0n, 2, false) - expect(result).toEqual(new Uint8Array([0x00, 0x00])) - }) - - it('converts multi-byte unsigned value in little-endian order', () => { - // 0x0102 → LE bytes [0x02, 0x01]. Wire format the runtime - // memcpy's straight into IEC ints — every supported target - // (AVR / ARM Cortex-M / x86_64) is little-endian. - const result = integerToBuffer(0x0102n, 2, false) - expect(result).toEqual(new Uint8Array([0x02, 0x01])) - }) - - it('converts 4-byte value in little-endian order', () => { - const result = integerToBuffer(0xdeadbeefn, 4, false) - expect(result).toEqual(new Uint8Array([0xef, 0xbe, 0xad, 0xde])) - }) - - it('converts negative 4-byte signed value', () => { - // -1 in 4-byte signed = 0xFF FF FF FF - const result = integerToBuffer(-1n, 4, true) - expect(result).toEqual(new Uint8Array([0xff, 0xff, 0xff, 0xff])) - }) -}) - -// --------------------------------------------------------------------------- -// parseFloatValue -// --------------------------------------------------------------------------- - -describe('parseFloatValue', () => { - it('parses valid float string', () => { - expect(parseFloatValue('3.14', 4)).toBeCloseTo(3.14, 2) - }) - - it('parses integer as float', () => { - expect(parseFloatValue('42', 8)).toBe(42) - }) - - it('trims whitespace', () => { - expect(parseFloatValue(' 1.5 ', 4)).toBe(1.5) - }) - - it('returns null for NaN', () => { - expect(parseFloatValue('NaN', 4)).toBeNull() - }) - - it('returns null for Infinity', () => { - expect(parseFloatValue('Infinity', 4)).toBeNull() - }) - - it('returns null for -Infinity', () => { - expect(parseFloatValue('-Infinity', 4)).toBeNull() - }) - - it('returns null for non-numeric string', () => { - expect(parseFloatValue('abc', 4)).toBeNull() - }) - - it('returns null when float32 exceeds max', () => { - expect(parseFloatValue('3.5e38', 4)).toBeNull() - }) - - it('returns null when float32 exceeds negative max', () => { - expect(parseFloatValue('-3.5e38', 4)).toBeNull() - }) - - it('allows large values for 8-byte (double) precision', () => { - expect(parseFloatValue('3.5e38', 8)).toBeCloseTo(3.5e38) - }) - - it('handles negative float', () => { - expect(parseFloatValue('-2.5', 4)).toBe(-2.5) - }) - - it('handles zero', () => { - expect(parseFloatValue('0', 4)).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// floatToBuffer -// --------------------------------------------------------------------------- - -describe('floatToBuffer', () => { - // Wire format: little-endian IEEE 754, matching what the runtime - // memcpy's into the IEC `REAL`/`LREAL` variable on every supported - // target. The read-side decoder (`variable-sizes.ts:decodeWireValue`) - // uses `getFloat32(0, true)` — these tests confirm the writer agrees. - - it('writes float32 as 4 little-endian bytes', () => { - const result = floatToBuffer(1.0, 4) - expect(result.length).toBe(4) - const view = new DataView(result.buffer) - expect(view.getFloat32(0, true)).toBe(1.0) - // 1.0 IEEE 754 = 0x3F800000 → LE bytes [0x00, 0x00, 0x80, 0x3F] - expect(result).toEqual(new Uint8Array([0x00, 0x00, 0x80, 0x3f])) - }) - - it('writes float64 as 8 little-endian bytes', () => { - const result = floatToBuffer(3.141592653589793, 8) - expect(result.length).toBe(8) - const view = new DataView(result.buffer) - expect(view.getFloat64(0, true)).toBeCloseTo(3.141592653589793, 12) - }) - - it('handles zero', () => { - const result = floatToBuffer(0, 4) - expect(result.length).toBe(4) - const view = new DataView(result.buffer) - expect(view.getFloat32(0, true)).toBe(0) - }) - - it('writes 123.4 with the byte order the runtime expects', () => { - // Regression for the byte-swap bug: forcing a REAL to 123.4 - // used to store -429836352 on the runtime side because this - // helper wrote big-endian (0x42 0xF6 0xCC 0xCD) but the runtime - // memcpy'd those bytes into a little-endian float, swapping - // sign / exponent / mantissa. - const result = floatToBuffer(123.4, 4) - expect(result).toEqual(new Uint8Array([0xcd, 0xcc, 0xf6, 0x42])) - }) - - it('returns buffer of requested size for other sizes (no write)', () => { - const result = floatToBuffer(1.0, 2) - expect(result.length).toBe(2) - // No float write happens for non-4/non-8, so all zeros - expect(result).toEqual(new Uint8Array([0, 0])) - }) -}) - -// --------------------------------------------------------------------------- -// parseStringValue -// --------------------------------------------------------------------------- - -describe('parseStringValue', () => { - it('returns string for valid ASCII input', () => { - expect(parseStringValue('Hello')).toBe('Hello') - }) - - it('returns empty string for empty input', () => { - expect(parseStringValue('')).toBe('') - }) - - it('returns string at max length (126 chars)', () => { - const s = 'a'.repeat(126) - expect(parseStringValue(s)).toBe(s) - }) - - it('returns null when string exceeds 126 chars', () => { - const s = 'a'.repeat(127) - expect(parseStringValue(s)).toBeNull() - }) - - it('returns null for non-ASCII character', () => { - expect(parseStringValue('caf\u00e9')).toBeNull() - }) - - it('accepts all printable ASCII', () => { - const s = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~' - expect(parseStringValue(s)).toBe(s) - }) - - it('accepts control characters within ASCII range', () => { - expect(parseStringValue('\t\n\r')).toBe('\t\n\r') - }) -}) - -// --------------------------------------------------------------------------- -// stringToBuffer -// --------------------------------------------------------------------------- - -describe('stringToBuffer', () => { - it('creates buffer with length prefix and ASCII bytes', () => { - const result = stringToBuffer('Hi') - expect(result).toEqual(new Uint8Array([2, 72, 105])) // 'H'=72, 'i'=105 - }) - - it('creates buffer for empty string', () => { - const result = stringToBuffer('') - expect(result).toEqual(new Uint8Array([0])) - }) - - it('sets first byte to string length', () => { - const result = stringToBuffer('ABCDE') - expect(result[0]).toBe(5) - expect(result.length).toBe(6) - }) - - it('encodes each character as its char code', () => { - const result = stringToBuffer('A') - expect(result[1]).toBe(65) - }) -}) diff --git a/src/frontend/utils/endian.ts b/src/frontend/utils/endian.ts index 62474d483..dc88e0ec2 100644 --- a/src/frontend/utils/endian.ts +++ b/src/frontend/utils/endian.ts @@ -9,7 +9,7 @@ * and code-size-cheap on AVR; the editor (running on a host with * plenty of compute) does the adaptation. * - * - The editor's internal codecs (`floatToBuffer`, `integerToBuffer`, + * - The editor's internal codecs (`encodeForceValue`, * `decodeWireValue`, etc.) always produce / consume **little-endian** * bytes. The swap layer below normalises the wire bytes to that * canonical internal form on reads, and back to target-native on diff --git a/src/frontend/utils/iec-duration.ts b/src/frontend/utils/iec-duration.ts new file mode 100644 index 000000000..dcef7401e --- /dev/null +++ b/src/frontend/utils/iec-duration.ts @@ -0,0 +1,97 @@ +/** + * IEC 61131-3 duration literal parser — the inverse of `formatTimeValue` + * in `variable-sizes.ts`. + * + * strucpp stores TIME as int64 nanoseconds, so forcing a TIME variable + * means turning the user's `T#10s` / `1h30m` text into a nanosecond + * count. The watch panel renders durations without the `T#` prefix + * (`3s800ms`), so prefix-less input is accepted too — whatever the panel + * displays can be typed straight back in. + */ + +const NS_PER_UNIT: Record = { + d: 86_400_000_000_000n, + h: 3_600_000_000_000n, + m: 60_000_000_000n, + s: 1_000_000_000n, + ms: 1_000_000n, + us: 1_000n, + ns: 1n, +} + +/** Largest unit first — also the order IEC requires in a literal. */ +const UNIT_ORDER = ['d', 'h', 'm', 's', 'ms', 'us', 'ns'] + +const INT64_MIN = -(2n ** 63n) +const INT64_MAX = 2n ** 63n - 1n + +/** Two-character units come first so `ms` never tokenises as `m` + `s`. */ +const COMPONENT = /^(\d[\d_]*(?:\.\d[\d_]*)?)(ms|us|ns|d|h|m|s)/i + +const HINT = 'Use IEC duration units, e.g. T#10s, 1h30m, 250ms, T#-1s500ms.' + +/** + * Parse an IEC duration literal into signed nanoseconds. + * + * Accepts `T#` / `TIME#` / `LT#` / `LTIME#` or no prefix at all, a sign + * on either side of the prefix, `_` digit separators, unit chains + * (`1h30m`), overflow units (`90s`), and a fraction on the smallest + * unit (`1.5s`). Throws an Error with a user-facing message otherwise — + * callers surface it as-is. + */ +export function parseDurationLiteral(input: string): bigint { + let body = input.trim() + let negative = false + + if (/^[+-]/.test(body)) { + negative = body.startsWith('-') + body = body.slice(1) + } + + const prefix = /^(?:ltime|time|lt|t)#/i.exec(body) + if (prefix) body = body.slice(prefix[0].length) + + // IEC writes the sign after the prefix (`T#-5s`); tolerate either side. + if (/^[+-]/.test(body)) { + if (body.startsWith('-')) negative = !negative + body = body.slice(1) + } + + let rest = body.replace(/\s+/g, '') + if (rest === '') throw new Error(`Invalid TIME value: "${input}". ${HINT}`) + + let totalNs = 0n + let smallestSeen = -1 + let sawFraction = false + + while (rest !== '') { + const match = COMPONENT.exec(rest) + if (!match) throw new Error(`Invalid TIME value: "${input}". ${HINT}`) + + const [component, digits, unitText] = match + const unit = unitText.toLowerCase() + const position = UNIT_ORDER.indexOf(unit) + if (position <= smallestSeen) { + throw new Error(`TIME units must appear once, largest first (d h m s ms us ns): "${input}"`) + } + if (sawFraction) { + throw new Error(`Only the smallest unit of a TIME value may be fractional: "${input}"`) + } + + const [whole, fraction] = digits.replace(/_/g, '').split('.') + totalNs += BigInt(whole) * NS_PER_UNIT[unit] + if (fraction !== undefined) { + sawFraction = true + // Round half away from zero on the magnitude; the sign lands below. + const scale = 10n ** BigInt(fraction.length) + totalNs += (BigInt(fraction) * NS_PER_UNIT[unit] * 2n + scale) / (scale * 2n) + } + + smallestSeen = position + rest = rest.slice(component.length) + } + + const signed = negative ? -totalNs : totalNs + if (signed < INT64_MIN || signed > INT64_MAX) throw new Error(`TIME value out of range: "${input}"`) + return signed +} diff --git a/src/frontend/utils/variable-sizes.ts b/src/frontend/utils/variable-sizes.ts index 9e629a053..8fef9aeed 100644 --- a/src/frontend/utils/variable-sizes.ts +++ b/src/frontend/utils/variable-sizes.ts @@ -1,4 +1,5 @@ import type { PLCVariable } from '../../middleware/shared/ports/types' +import { parseDurationLiteral } from './iec-duration' import { type IECTypeMetadata, type IECWireFormat, lookupBaseType } from './iec-types-registry' /** @@ -325,10 +326,33 @@ export function encodeForceValue(input: string, typeName: string, enumValues?: s } /** - * Encode dispatcher keyed on `wireFormat`. TIME / DATE / TOD / DT / - * STRING / WSTRING force is not yet supported — those need IEC literal - * parsing (`T#…`, `D#…`) and string framing, which the debugger UI - * doesn't currently expose. + * Colour hint stored in the workspace's forced-variables map: `false` + * marks a forced-low value (rendered blue in the watch panel and the + * graphical editors), `true` forced-high. + */ +export function isForcedValueHigh(input: string): boolean { + // Drop a literal prefix so `T#-5s` reads as negative like `-5s` does. + const value = input + .trim() + .toLowerCase() + .replace(/^[a-z_]+#/, '') + return value !== 'false' && value !== '0' && !value.startsWith('-') +} + +/** + * Unwrap an IEC STRING literal (`'text'`) to its content. Input arrives + * trimmed, so quoting is also how a force value keeps leading/trailing + * spaces. + */ +function stripStringLiteral(input: string): string { + const quoted = /^'([\s\S]*)'$/.exec(input) + return quoted ? quoted[1] : input +} + +/** + * Encode dispatcher keyed on `wireFormat`. DATE / TOD / DT / WSTRING + * force is not yet supported — those need calendar-literal parsing + * (`D#…`, `TOD#…`, `DT#…`) and UTF-16 framing. */ function encodeByWireFormat(originalInput: string, numericInput: string, meta: IECTypeMetadata): Uint8Array { switch (meta.wireFormat) { @@ -382,11 +406,28 @@ function encodeByWireFormat(originalInput: string, numericInput: string, meta: I new DataView(buf.buffer).setFloat64(0, n, true) return buf } - case 'duration-ns-i64': + case 'duration-ns-i64': { + const buf = new Uint8Array(8) + new DataView(buf.buffer).setBigInt64(0, parseDurationLiteral(originalInput), true) + return buf + } + case 'len8-utf8': { + const text = stripStringLiteral(originalInput) + if (text.length > DEBUG_STRING_CAP) { + throw new Error(`STRING value too long: ${text.length} characters (max ${DEBUG_STRING_CAP})`) + } + const buf = new Uint8Array(1 + text.length) + buf[0] = text.length + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + if (code > 127) throw new Error(`STRING value must be ASCII: "${originalInput}"`) + buf[i + 1] = code + } + return buf + } case 'datetime-ns-i64': case 'date-ns-i64': case 'tod-ns-i64': - case 'len8-utf8': case 'len8-utf16le': throw new Error(`Forcing ${meta.name} values is not supported yet`) default: { diff --git a/src/frontend/utils/variable-types.ts b/src/frontend/utils/variable-types.ts deleted file mode 100644 index d023c9081..000000000 --- a/src/frontend/utils/variable-types.ts +++ /dev/null @@ -1,162 +0,0 @@ -export interface VariableTypeInfo { - byteSize: number - signed: boolean -} - -export const getVariableTypeInfo = (type: string): VariableTypeInfo | null => { - const normalizedType = type.toLowerCase() - - switch (normalizedType) { - case 'bool': - return { byteSize: 1, signed: false } - case 'sint': - return { byteSize: 1, signed: true } - case 'usint': - case 'byte': - return { byteSize: 1, signed: false } - case 'int': - return { byteSize: 2, signed: true } - case 'uint': - case 'word': - return { byteSize: 2, signed: false } - case 'dint': - return { byteSize: 4, signed: true } - case 'udint': - case 'dword': - return { byteSize: 4, signed: false } - case 'lint': - return { byteSize: 8, signed: true } - case 'ulint': - case 'lword': - return { byteSize: 8, signed: false } - case 'real': - return { byteSize: 4, signed: true } - case 'lreal': - return { byteSize: 8, signed: true } - case 'string': - return { byteSize: 127, signed: false } - default: - return null - } -} - -export const parseIntegerValue = (value: string, typeInfo: VariableTypeInfo): bigint | null => { - try { - let parsedValue: bigint - - const trimmedValue = value.trim() - if (trimmedValue.startsWith('0x') || trimmedValue.startsWith('0X')) { - parsedValue = BigInt(trimmedValue) - } else if (trimmedValue.startsWith('2#')) { - parsedValue = BigInt('0b' + trimmedValue.substring(2)) - } else if (trimmedValue.startsWith('8#')) { - parsedValue = BigInt('0o' + trimmedValue.substring(2)) - } else if (trimmedValue.startsWith('16#')) { - parsedValue = BigInt('0x' + trimmedValue.substring(3)) - } else { - parsedValue = BigInt(trimmedValue) - } - - const maxValue = typeInfo.signed - ? BigInt(2) ** BigInt(typeInfo.byteSize * 8 - 1) - BigInt(1) - : BigInt(2) ** BigInt(typeInfo.byteSize * 8) - BigInt(1) - const minValue = typeInfo.signed ? -(BigInt(2) ** BigInt(typeInfo.byteSize * 8 - 1)) : BigInt(0) - - if (parsedValue < minValue || parsedValue > maxValue) { - return null - } - - return parsedValue - } catch { - return null - } -} - -export const integerToBuffer = (value: bigint, byteSize: number, signed: boolean): Uint8Array => { - const buffer = new Uint8Array(byteSize) - - let workingValue = value - if (signed && value < BigInt(0)) { - const maxUnsigned = BigInt(2) ** BigInt(byteSize * 8) - workingValue = maxUnsigned + value - } - - // Little-endian: matches what the runtime memcpy's into the IEC - // integer variable on every supported target. The previous - // descending-index loop emitted big-endian bytes, which is why - // forcing a DINT to e.g. 0x12345678 ended up stored as 0x78563412. - // BOOL forcing didn't hit this because it bypasses this helper - // (single-byte inline `new Uint8Array([1])`). - for (let i = 0; i < byteSize; i++) { - buffer[i] = Number(workingValue & BigInt(0xff)) - workingValue = workingValue >> BigInt(8) - } - - return buffer -} - -export const parseFloatValue = (value: string, byteSize: number): number | null => { - const trimmedValue = value.trim() - const parsedValue = parseFloat(trimmedValue) - - if (isNaN(parsedValue) || !isFinite(parsedValue)) { - return null - } - - if (byteSize === 4) { - const maxFloat32 = 3.4028235e38 - const minFloat32 = -3.4028235e38 - if (parsedValue > maxFloat32 || parsedValue < minFloat32) { - return null - } - } - - return parsedValue -} - -export const floatToBuffer = (value: number, byteSize: number): Uint8Array => { - const buffer = new Uint8Array(byteSize) - const dataView = new DataView(buffer.buffer) - - // Little-endian: the runtime memcpy's these bytes straight into a - // `float`/`double`, and every supported target (AVR, ARM Cortex-M, - // x86_64) stores IEEE 754 little-endian. The READ decoder in - // `variable-sizes.ts` and the parallel encoder in - // `encodeForceValue` both already use `true` here — this writer - // used to disagree, which made forcing a REAL to 123.4 store the - // byte-swapped value (e.g. -429836352). - if (byteSize === 4) { - dataView.setFloat32(0, value, true) - } else if (byteSize === 8) { - dataView.setFloat64(0, value, true) - } - - return buffer -} - -export const parseStringValue = (value: string): string | null => { - if (value.length > 126) { - return null - } - - for (let i = 0; i < value.length; i++) { - const charCode = value.charCodeAt(i) - if (charCode > 127) { - return null - } - } - - return value -} - -export const stringToBuffer = (value: string): Uint8Array => { - const buffer = new Uint8Array(1 + value.length) - - buffer[0] = value.length - - for (let i = 0; i < value.length; i++) { - buffer[i + 1] = value.charCodeAt(i) - } - - return buffer -} From 3da85070966c5a7803313483ec1f61cbb9d426e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Sat, 1 Aug 2026 17:02:19 -0300 Subject: [PATCH 09/79] fix(graphical): never report a POU as saved when its flow write-back fails (DOPE-495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graphical flow write-back validates with zod before persisting into `pou.body.value`. On failure it returned silently, so the save flow went on to serialize the stale pre-edit body, mark every file saved, clear every `updated` flag and show "Changes saved!". The user's edit survived only in memory and died with the app. `runWriteBack` now reports failure and logs the zod issues; `flushFlowWriteBacks` returns the POUs whose body is still stale. The save paths handle those per-POU: the flow keeps `updated`, the file stays dirty, its undo baseline is not reset, and a failure toast names it — while every other POU still saves. Single-file save aborts before writing rather than overwriting disk with the stale body. Flush also had a hole: it swept only POUs with a live debounce timer, so a timer that had already fired and failed left nothing pending and the save reported success anyway. It now writes back every `updated` flow. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Btq4UkctubeNQvh2cYAUv --- .../services/__tests__/save-actions.test.ts | 135 ++++++++++++++++-- src/frontend/services/save-actions.ts | 40 ++++-- .../store/__tests__/flow-writeback.test.ts | 58 +++++++- .../store/__tests__/shared-slice.test.ts | 10 ++ .../store/slices/shared/flow-writeback.ts | 44 ++++-- src/frontend/store/slices/shared/slice.ts | 5 +- src/frontend/store/slices/shared/types.ts | 2 +- 7 files changed, 257 insertions(+), 37 deletions(-) diff --git a/src/frontend/services/__tests__/save-actions.test.ts b/src/frontend/services/__tests__/save-actions.test.ts index dac639efe..6ce34a3f8 100644 --- a/src/frontend/services/__tests__/save-actions.test.ts +++ b/src/frontend/services/__tests__/save-actions.test.ts @@ -1,22 +1,129 @@ /** * save-actions.ts test file * - * All exported functions (executeSaveProject, executeSaveFile, executeSaveActiveFile) - * depend on `openPLCStoreBase.getState()` to read the Zustand store and on - * `projectPort` (an async interface) to persist data. They also call `toast()` - * which mutates module-level state. - * - * Because these functions are NOT pure (they read/mutate external state), - * they cannot be tested without mocking. The pure helpers they delegate to - * (sanitizePou, collectDebugVariables, serializePouToText, etc.) are covered - * by their own dedicated test suites. - * - * SKIPPED: requires jest.mock / vi.mock for openPLCStoreBase and projectPort. + * The pure helpers these functions delegate to (sanitizePou, + * collectDebugVariables, serializePouToText, …) are covered by their own + * suites. The cases below drive the real store singleton to pin the DOPE-495 + * contract: a graphical flow that fails schema validation keeps a stale + * `pou.body.value`, so it must never be reported as saved. */ +import type { PlatformCapabilities } from '../../../middleware/shared/ports/platform-capabilities' +import type { ProjectPort } from '../../../middleware/shared/ports/project-port' +import { openPLCStoreBase } from '../../store' +import type { LadderFlowType } from '../../store/slices/ladder' +import { getMemoryState } from '../../utils/toast' +import { executeSaveFile, executeSaveProject } from '../save-actions' + +const capabilities = { isNativeApplication: true } as PlatformCapabilities + +const lastToast = () => getMemoryState().toasts[0] + +function makeProjectPort(): ProjectPort { + return { + saveProject: vi.fn().mockResolvedValue({ success: true }), + saveFile: vi.fn().mockResolvedValue({ success: true }), + } as unknown as ProjectPort +} + +function createLadderPou(name: string) { + const state = openPLCStoreBase.getState() + state.pouActions.create({ type: 'program', name, language: 'ld' }) + state.ladderFlowActions.startLadderRung({ + editorName: name, + rungId: `rung_${name}_1`, + defaultBounds: [300, 100], + reactFlowViewport: [300, 100], + }) + state.ladderFlowActions.setFlowUpdated({ editorName: name, updated: true }) +} + +/** Drop `defaultBounds` / `reactFlowViewport` so the flow fails the zod guard. */ +function corruptFlow(name: string) { + const flow = openPLCStoreBase.getState().ladderFlows.find((f) => f.name === name) + openPLCStoreBase.getState().ladderFlowActions.addLadderFlow({ + name, + updated: true, + rungs: (flow?.rungs ?? []).map((rung) => ({ id: rung.id, comment: '', nodes: [], edges: [] })), + } as unknown as LadderFlowType) + openPLCStoreBase.getState().ladderFlowActions.setFlowUpdated({ editorName: name, updated: true }) +} + +const flowUpdated = (name: string) => openPLCStoreBase.getState().ladderFlows.find((f) => f.name === name)?.updated +const fileSaved = (name: string) => openPLCStoreBase.getState().files[name]?.saved + describe('save-actions', () => { - it('is skipped because all exported functions require store mocking', () => { - // Intentionally empty — see file-level comment. - expect(true).toBe(true) + let warn: ReturnType + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + openPLCStoreBase.getState().ladderFlowActions.clearLadderFlows() + }) + + afterEach(() => { + warn.mockRestore() + }) + + describe('executeSaveProject', () => { + it('reports success and clears the updated flag for a valid flow', async () => { + createLadderPou('ValidPou') + + const result = await executeSaveProject(makeProjectPort(), capabilities) + + expect(result.success).toBe(true) + expect(flowUpdated('ValidPou')).toBe(false) + }) + + it('does not report a POU as saved when its flow fails validation', async () => { + createLadderPou('BrokenPou') + corruptFlow('BrokenPou') + + const result = await executeSaveProject(makeProjectPort(), capabilities) + + expect(result.success).toBe(false) + // Keeping `updated` set is what lets a later edit retry the write-back. + expect(flowUpdated('BrokenPou')).toBe(true) + expect(fileSaved('BrokenPou')).toBe(false) + expect(lastToast()).toMatchObject({ title: 'Some changes were not saved', variant: 'fail' }) + }) + + it('still saves the valid POUs alongside a failing one', async () => { + createLadderPou('GoodPou') + createLadderPou('BadPou') + corruptFlow('BadPou') + + const projectPort = makeProjectPort() + const result = await executeSaveProject(projectPort, capabilities) + + expect(result.success).toBe(false) + expect(projectPort.saveProject).toHaveBeenCalled() + expect(flowUpdated('GoodPou')).toBe(false) + expect(fileSaved('GoodPou')).toBe(true) + }) + }) + + describe('executeSaveFile', () => { + it('refuses to write the stale body of a failing flow', async () => { + createLadderPou('BrokenFile') + corruptFlow('BrokenFile') + + const projectPort = makeProjectPort() + const result = await executeSaveFile('BrokenFile', projectPort, capabilities) + + expect(result.success).toBe(false) + expect(projectPort.saveFile).not.toHaveBeenCalled() + expect(flowUpdated('BrokenFile')).toBe(true) + }) + + it('writes a valid flow normally', async () => { + createLadderPou('ValidFile') + + const projectPort = makeProjectPort() + const result = await executeSaveFile('ValidFile', projectPort, capabilities) + + expect(result.success).toBe(true) + expect(projectPort.saveFile).toHaveBeenCalled() + expect(flowUpdated('ValidFile')).toBe(false) + }) }) }) diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 5daf9e5e7..723416e55 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -324,8 +324,9 @@ export async function executeSaveProject( ): Promise<{ success: boolean }> { // Run any pending debounced graphical write-backs before reading state: // a save landing inside the debounce window must serialize the fresh - // POU bodies, not the pre-edit ones. - flushFlowWriteBacks(openPLCStoreBase.getState) + // POU bodies, not the pre-edit ones. Flows that fail validation keep a + // stale body, so they must not be reported as saved (DOPE-495). + const staleFlows = flushFlowWriteBacks(openPLCStoreBase.getState) const state = openPLCStoreBase.getState() // Persist gate. Every save path — Ctrl+S, File → Save, auto-save after // a rename/delete, the AI panel — funnels through here. When the viewer @@ -339,7 +340,7 @@ export async function executeSaveProject( } const { project, pendingDeletions } = state const { setEditingState } = state.workspaceActions - const { setAllToSaved } = state.fileActions + const { setAllToSaved, updateFile } = state.fileActions const { markAllSaved } = state.snapshotActions const deletionsBeforeSave = [...pendingDeletions] @@ -439,21 +440,34 @@ export async function executeSaveProject( }) state.projectActions.clearPendingDeletions() - setEditingState('saved') + setEditingState(staleFlows.length > 0 ? 'unsaved' : 'saved') setAllToSaved() - markAllSaved() + markAllSaved(staleFlows) - // Reset graphical flow state: clear selections and updated flags + // Reset graphical flow state: clear selections and updated flags. + // A stale flow keeps `updated` set and its file dirty — clearing them + // would strand the in-memory edit with no way back to disk. for (const flow of state.ladderFlows) { state.ladderFlowActions.clearSelections({ editorName: flow.name }) + if (staleFlows.includes(flow.name)) continue state.ladderFlowActions.setFlowUpdated({ editorName: flow.name, updated: false }) } for (const flow of state.fbdFlows) { state.fbdFlowActions.clearSelections({ editorName: flow.name }) + if (staleFlows.includes(flow.name)) continue state.fbdFlowActions.setFlowUpdated({ editorName: flow.name, updated: false }) } + for (const name of staleFlows) { + updateFile({ name, saved: false }) + } - if (!capabilities.isNativeApplication) { + if (staleFlows.length > 0) { + toast({ + title: 'Some changes were not saved', + description: `The graphical body of ${staleFlows.join(', ')} is invalid and could not be written to disk. Every other file was saved.`, + variant: 'fail', + }) + } else if (!capabilities.isNativeApplication) { toast({ title: 'Changes saved!', description: 'The project was saved successfully!', @@ -468,7 +482,9 @@ export async function executeSaveProject( variant: 'fail', }) } - return { success: res.success } + // A stale flow means the user's graphical edit never reached disk, so + // callers that gate on the save (build, close-project) must not proceed. + return { success: res.success && staleFlows.length === 0 } } catch { setEditingState('unsaved') toast({ @@ -497,7 +513,7 @@ export async function executeSaveFile( capabilities: PlatformCapabilities, ): Promise<{ success: boolean }> { // See executeSaveProject — same pending write-back flush requirement. - flushFlowWriteBacks(openPLCStoreBase.getState) + const staleFlows = flushFlowWriteBacks(openPLCStoreBase.getState) const state = openPLCStoreBase.getState() // See executeSaveProject for rationale — same persist gate. if (!state.workspace.canEdit) { @@ -524,6 +540,12 @@ export async function executeSaveFile( return { success: false } } + // Writing the stale body would overwrite disk with pre-edit content and + // then report success — abort instead (DOPE-495). + if (staleFlows.includes(fileName)) { + return fail(`The graphical body of "${fileName}" is invalid, so the file was not written to disk.`) + } + try { // Use the same canonical serializer as the full-project save path so // both flows agree on what bytes hit disk. For POUs and JSON files this diff --git a/src/frontend/store/__tests__/flow-writeback.test.ts b/src/frontend/store/__tests__/flow-writeback.test.ts index 649595c8c..36c5d60e0 100644 --- a/src/frontend/store/__tests__/flow-writeback.test.ts +++ b/src/frontend/store/__tests__/flow-writeback.test.ts @@ -173,11 +173,13 @@ describe('flow write-back scheduler', () => { } as unknown as LadderFlowType) store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'Main', updated: true }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) scheduleFlowWriteBack(getState, 'Main', 'ld') vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) expect(ladderBody('Main')?.rungs).toHaveLength(0) expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(true) + warn.mockRestore() }) }) @@ -210,13 +212,67 @@ describe('flow write-back scheduler', () => { expect(ladderBody('B')?.rungs).toHaveLength(1) }) - it('is a no-op when nothing is pending', () => { + it('leaves the other language untouched when scoped to one POU', () => { makeDirtyLadderPou('Main') + store.getState().pouActions.create({ type: 'program', name: 'FbdMain', language: 'fbd' }) + store.getState().fbdFlowActions.startFBDRung({ editorName: 'FbdMain' }) + store.getState().fbdFlowActions.setFlowUpdated({ editorName: 'FbdMain', updated: true }) + + expect(flushFlowWriteBacks(getState, 'Main')).toEqual([]) + expect(store.getState().fbdFlows.find((f) => f.name === 'FbdMain')?.updated).toBe(true) + }) + + it('writes back an updated flow that has no pending timer', () => { + makeDirtyLadderPou('Main') + + expect(flushFlowWriteBacks(getState)).toEqual([]) + expect(ladderBody('Main')?.rungs).toHaveLength(1) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(false) + }) + + it('is a no-op when no flow is marked updated', () => { + makeDirtyLadderPou('Main') + flushFlowWriteBacks(getState) const bodyBefore = ladderBody('Main') + expect(flushFlowWriteBacks(getState)).toEqual([]) + expect(ladderBody('Main')).toBe(bodyBefore) + }) + + it('reports the POU and leaves the body stale when the flow fails validation', () => { + makeDirtyLadderPou('Main') flushFlowWriteBacks(getState) + const bodyBefore = ladderBody('Main') + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const flow = store.getState().ladderFlows.find((f) => f.name === 'Main') + store.getState().ladderFlowActions.setRungs({ + editorName: 'Main', + // `defaultBounds` is required by the schema — dropping it makes the flow invalid. + rungs: (flow?.rungs ?? []).map(({ defaultBounds: _defaultBounds, ...rung }) => rung) as never, + }) + expect(flushFlowWriteBacks(getState)).toEqual(['Main']) expect(ladderBody('Main')).toBe(bodyBefore) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(true) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('reports a failing FBD flow', () => { + store.getState().pouActions.create({ type: 'program', name: 'FbdMain', language: 'fbd' }) + store.getState().fbdFlowActions.startFBDRung({ editorName: 'FbdMain' }) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // `nodes` is required by the schema — dropping it makes the flow invalid. + store.getState().fbdFlowActions.setRung({ + editorName: 'FbdMain', + rung: { comment: '', edges: [], selectedNodes: [] } as never, + }) + + expect(flushFlowWriteBacks(getState)).toEqual(['FbdMain']) + expect(warn).toHaveBeenCalled() + warn.mockRestore() }) }) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 0b7720d91..f55b72520 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -2158,6 +2158,16 @@ describe('createSharedSlice', () => { expect(store.getState().undoRedo['P1'].savedAtDepth).toBe(1) expect(store.getState().undoRedo['P2'].savedAtDepth).toBe(2) }) + + it('skips the excluded POUs', () => { + store.getState().snapshotActions.pushToHistory('P1', { variables: [], body: 'v1' }) + store.getState().snapshotActions.pushToHistory('P2', { variables: [], body: 'v1' }) + + store.getState().snapshotActions.markAllSaved(['P2']) + + expect(store.getState().undoRedo['P1'].savedAtDepth).toBe(1) + expect(store.getState().undoRedo['P2'].savedAtDepth).toBe(0) + }) }) // ----------------------------------------------------------------------- diff --git a/src/frontend/store/slices/shared/flow-writeback.ts b/src/frontend/store/slices/shared/flow-writeback.ts index 07e3cd491..605d54d9e 100644 --- a/src/frontend/store/slices/shared/flow-writeback.ts +++ b/src/frontend/store/slices/shared/flow-writeback.ts @@ -34,13 +34,14 @@ type GetWriteBackState = () => SharedRootState const pendingWriteBacks = new Map }>() -function runWriteBack(getState: GetWriteBackState, pouName: string, language: FlowLanguage): void { +/** @returns `false` when the flow is invalid and `pou.body.value` is left stale. */ +function runWriteBack(getState: GetWriteBackState, pouName: string, language: FlowLanguage): boolean { const state = getState() const flow = language === 'ld' ? state.ladderFlows.find((f) => f.name === pouName) : state.fbdFlows.find((f) => f.name === pouName) - if (!flow?.updated) return + if (!flow?.updated) return true // Validate with zod but persist the raw object (minus the transient // `updated` flag). Using the parsed result would silently strip every @@ -48,13 +49,20 @@ function runWriteBack(getState: GetWriteBackState, pouName: string, language: Fl // byte-drifting the serialized POU vs. the loaded disk copy — phantom // "Modified" entries in Source Control (see DOPE-477). const schema = language === 'ld' ? zodLadderFlowSchema : zodFBDFlowSchema - if (!schema.safeParse(flow).success) return + const validation = schema.safeParse(flow) + if (!validation.success) { + console.warn(`[flow-writeback] "${pouName}" (${language}) failed validation — body left stale`, { + issues: validation.error.issues, + }) + return false + } const { updated: _updated, ...flowBody } = flow state.projectActions.updatePou({ name: pouName, content: { language, value: flowBody } }) const flowActions = language === 'ld' ? state.ladderFlowActions : state.fbdFlowActions flowActions.setFlowUpdated({ editorName: pouName, updated: false }) + return true } /** @@ -78,14 +86,30 @@ export function scheduleFlowWriteBack(getState: GetWriteBackState, pouName: stri pendingWriteBacks.set(pouName, { language, timer }) } -/** Run pending write-backs immediately — all of them, or a single POU's. */ -export function flushFlowWriteBacks(getState: GetWriteBackState, pouName?: string): void { - for (const [name, pending] of [...pendingWriteBacks]) { - if (pouName !== undefined && name !== pouName) continue - clearTimeout(pending.timer) - pendingWriteBacks.delete(name) - runWriteBack(getState, name, pending.language) +/** + * Run pending write-backs immediately — all of them, or a single POU's. + * + * Every `updated` flow is written back, not just the ones with a live timer: + * a timer that already fired and failed validation leaves no pending entry + * behind, so a pending-only sweep would report success for a POU whose body + * is still stale (DOPE-495). + * + * @returns names of POUs whose body could not be updated. + */ +export function flushFlowWriteBacks(getState: GetWriteBackState, pouName?: string): string[] { + cancelFlowWriteBacks(pouName) + + const state = getState() + const failed: string[] = [] + for (const flow of state.ladderFlows) { + if (pouName !== undefined && flow.name !== pouName) continue + if (flow.updated && !runWriteBack(getState, flow.name, 'ld')) failed.push(flow.name) + } + for (const flow of state.fbdFlows) { + if (pouName !== undefined && flow.name !== pouName) continue + if (flow.updated && !runWriteBack(getState, flow.name, 'fbd')) failed.push(flow.name) } + return failed } /** Drop pending write-backs without running them (project open). */ diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 016c920bb..8c07a150f 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -979,10 +979,11 @@ const createSharedSlice: StateCreator = (s ) }, - markAllSaved: () => { + markAllSaved: (except) => { setState( produce((state: SharedRootState) => { - for (const history of Object.values(state.undoRedo)) { + for (const [pouName, history] of Object.entries(state.undoRedo)) { + if (except?.includes(pouName)) continue history.savedAtDepth = history.past.length } }), diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index ba3efb36a..f3d3b824c 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -117,7 +117,7 @@ export type EtherCATDeviceActions = { export type SnapshotActions = { pushToHistory: (pouName: string, snapshot: PouHistorySnapshot) => void markSaved: (pouName: string) => void - markAllSaved: () => void + markAllSaved: (except?: readonly string[]) => void undo: (pouName: string) => void redo: (pouName: string) => void } From 3d10a3b6304deba5a474af314ce71fce2db1ebaf Mon Sep 17 00:00:00 2001 From: JulioSergioFS Date: Sun, 2 Aug 2026 19:27:16 -0300 Subject: [PATCH 10/79] fix(modbus): zero IEC buffers on read failure when the I/O group asks for it --- .../generate-modbus-master-config.test.ts | 42 +++++++++++++++++++ .../modbus/generate-modbus-master-config.ts | 9 ++++ 2 files changed, 51 insertions(+) diff --git a/src/backend/shared/utils/modbus/__tests__/generate-modbus-master-config.test.ts b/src/backend/shared/utils/modbus/__tests__/generate-modbus-master-config.test.ts index e29d95644..c4ba27dd9 100644 --- a/src/backend/shared/utils/modbus/__tests__/generate-modbus-master-config.test.ts +++ b/src/backend/shared/utils/modbus/__tests__/generate-modbus-master-config.test.ts @@ -112,6 +112,48 @@ describe('generateModbusMasterConfig', () => { expect(ioPoints[0].iec_location).toBe('%MW0') expect(ioPoints[0].len).toBe(10) expect(ioPoints[0].cycle_time_ms).toBe(100) + expect(ioPoints[0].error_handling).toBe('keep-last-value') + }) + + // Regression, openplc-editor#691: the per-group "Keep last value / Set to + // zero" choice existed only in the UI. It was stored in the project and + // dropped here, so the runtime never learned about it and always kept the + // last value when a device went unreachable. + it('emits the error handling each IO group is configured with', () => { + const device = makeTcpDevice() + device.modbusTcpConfig!.ioGroups[0].errorHandling = 'set-to-zero' + + const parsed = JSON.parse(generateModbusMasterConfig([device])!) + + expect(parsed[0].config.io_points[0].error_handling).toBe('set-to-zero') + }) + + it('emits error handling per point, not per device', () => { + const device = makeTcpDevice() + const [firstGroup] = device.modbusTcpConfig!.ioGroups + device.modbusTcpConfig!.ioGroups = [ + { ...firstGroup, id: 'g1', errorHandling: 'set-to-zero' }, + { ...firstGroup, id: 'g2', errorHandling: 'keep-last-value' }, + ] + + const parsed = JSON.parse(generateModbusMasterConfig([device])!) + + expect(parsed[0].config.io_points.map((p: { error_handling: string }) => p.error_handling)).toEqual([ + 'set-to-zero', + 'keep-last-value', + ]) + }) + + it('defaults to keeping the last value when a group predates the field', () => { + // Projects saved before the option shipped have no value stored; the + // runtime's historical behaviour is to keep the last value. + const device = makeTcpDevice() + const group = device.modbusTcpConfig!.ioGroups[0] + delete (group as { errorHandling?: unknown }).errorHandling + + const parsed = JSON.parse(generateModbusMasterConfig([device])!) + + expect(parsed[0].config.io_points[0].error_handling).toBe('keep-last-value') }) it('generates RTU config with serial port parameters', () => { diff --git a/src/backend/shared/utils/modbus/generate-modbus-master-config.ts b/src/backend/shared/utils/modbus/generate-modbus-master-config.ts index 661c77333..23b5b363d 100644 --- a/src/backend/shared/utils/modbus/generate-modbus-master-config.ts +++ b/src/backend/shared/utils/modbus/generate-modbus-master-config.ts @@ -6,6 +6,12 @@ interface ModbusMasterIOPoint { iec_location: string len: number cycle_time_ms: number + /** What the runtime does with this point's IEC buffer while the device is + * unreachable. The UI has offered the choice per I/O group for a while, but + * it never reached the config — so the runtime always kept the last value + * (openplc-editor#691). Emitted for every point so a config never leaves + * the behaviour implicit. */ + error_handling: ModbusIOGroup['errorHandling'] } // Base device config with common fields @@ -83,6 +89,9 @@ const convertIOGroupToIOPoint = (ioGroup: ModbusIOGroup): ModbusMasterIOPoint => iec_location: iecLocation, len: ioGroup.length, cycle_time_ms: ioGroup.cycleTime, + // Groups saved before the field existed have no value; the runtime's + // historical behaviour is to keep the last value, so default to it. + error_handling: ioGroup.errorHandling ?? 'keep-last-value', } } From c3dac157ddd967c536f833fe19295ff55918a2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 3 Jul 2026 10:53:45 +0200 Subject: [PATCH 11/79] feat(debugger): always-on serial debugger decoupled from full Modbus Expose the debug function codes over serial without enabling full Modbus RTU/TCP. A new DEBUGGER_ENABLED gate brings up the serial port and the debug FCs without allocating any operation buffers (coils/holding/input regs), saving SRAM on small AVR boards. When Modbus is enabled behaviour is unchanged. Firmware: - MB_SERIAL_ACTIVE gate (MBSERIAL || DEBUGGER_ENABLED) guards serial RX/framing - debug-only setup path (Serial/115200/slave 1 defaults, overridable via DEBUG_IFACE/DEBUG_BAUD/DEBUG_SLAVE); no init_mbregs()/mapEmptyBuffers() - process_mbpacket() gates operation FCs under MODBUS_ENABLED -> operation requests return ILLEGAL_FUNCTION in debug-only builds - new FCs 0x46 status, 0x47 version, 0x48 board-id (ArduinoUniqueID, with a compilable fallback); RTU framing + CRC-bypass wired for all three - OPENPLC_RUNTIME_VERSION in openplc_version.h Editor/shared: - ArduinoUniqueID added to GLOBAL_LIBRARIES - mirrored FC enums (editor + simulator) - modbus-pdu build/parse helpers for the 3 new FCs (100% covered) - ModbusRtuClient getStatus/getVersion/getBoardId (100% covered) - generate-defines emits the //Debugger block for baremetal targets when full Modbus is off - simulator debug E2E cases for 0x46/0x47/0x48 (gated by CHRIS_DEMO_HEX) Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/sources/Baremetal/Baremetal.ino | 25 +- resources/sources/Baremetal/ModbusSlave.cpp | 115 +++++++- resources/sources/Baremetal/ModbusSlave.h | 34 ++- resources/sources/Baremetal/openplc_version.h | 19 ++ .../editor/compiler/compiler-module.ts | 4 + src/backend/editor/modbus/modbus-client.ts | 3 + .../__tests__/generate-defines.test.ts | 41 +++ .../shared/compile/steps/generate-defines.ts | 18 ++ .../shared/debug/__tests__/modbus-pdu.test.ts | 185 +++++++++++++ src/backend/shared/debug/modbus-pdu.ts | 124 ++++++++- src/backend/shared/debug/types.ts | 32 +++ .../simulator/__tests__/debug-e2e.test.ts | 30 +++ .../__tests__/modbus-rtu-client.test.ts | 248 ++++++++++++++++++ .../shared/simulator/modbus-rtu-client.ts | 106 ++++++++ src/backend/shared/simulator/types.ts | 3 + 15 files changed, 977 insertions(+), 10 deletions(-) create mode 100644 resources/sources/Baremetal/openplc_version.h diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 126e6478e..adcbeaf32 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -30,7 +30,7 @@ #include "defines.h" #include "arduino_runtime_glue.h" -#ifdef MODBUS_ENABLED +#if defined(MODBUS_ENABLED) || defined(DEBUGGER_ENABLED) #include "ModbusSlave.h" #endif @@ -178,6 +178,15 @@ void setup() init_mbregs(MAX_ANALOG_OUTPUT + MAX_MEMORY_WORD, MAX_MEMORY_DWORD, MAX_MEMORY_LWORD, MAX_DIGITAL_OUTPUT, MAX_ANALOG_INPUT, MAX_DIGITAL_INPUT); mapEmptyBuffers(); + #elif defined(DEBUGGER_ENABLED) + // Always-on debugger without full Modbus: bring up the serial port and + // the Modbus RTU framing/slave id ONLY. The debugger reads/writes IEC + // variables directly through the strucpp debug table (openplc_debug_*), + // so it needs NO operation buffers — init_mbregs()/mapEmptyBuffers() are + // deliberately not called here, saving SRAM on small boards. + DEBUG_IFACE.begin(DEBUG_BAUD); + mbconfig_serial_iface(&DEBUG_IFACE, DEBUG_BAUD, -1); + modbus.slaveid = DEBUG_SLAVE; #endif setupCycleDelay(base_tick_ns); @@ -369,8 +378,12 @@ void scheduler() sketch_loop(); #endif - #ifdef MODBUS_ENABLED + #if defined(MODBUS_ENABLED) modbusTask(); + #elif defined(DEBUGGER_ENABLED) + // Debug-only: poll the serial transport for debugger requests. No buffer + // sync (modbusTask's mirror loops) because there are no operation buffers. + mbtask(); #endif if (!first_cycle) @@ -392,12 +405,18 @@ void loop() last_run += scan_cycle; } - #ifdef MODBUS_ENABLED + #if defined(MODBUS_ENABLED) // Only run Modbus task again if we have at least 10ms gap until the next cycle if ((micros() - last_run) >= 10000) { modbusTask(); } + #elif defined(DEBUGGER_ENABLED) + // Debug-only: give the debugger extra serial-poll time between cycles too. + if ((micros() - last_run) >= 10000) + { + mbtask(); + } #endif #ifdef SIMULATOR_MODE diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index 80d113e9d..8350f6b1d 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -12,6 +12,15 @@ Copyright (C) 2022 OpenPLC - Thiago Alves // of the precompiled OpenPLCUserLib archive built with -std=gnu++17. #include "arduino_runtime_glue.h" +// ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. +// It supports AVR/megaAVR/SAM/SAMD/STM32/ESP/RP2040/Teensy. On a core without +// support (or when a board intentionally opts out via OPENPLC_NO_UNIQUE_ID), +// the board-id handler returns id_len = 0 instead of failing to compile. +#ifndef OPENPLC_NO_UNIQUE_ID + #include + #define OPENPLC_HAS_UNIQUE_ID +#endif + //Global Modbus vars struct MBinfo modbus; uint8_t mb_frame[MAX_MB_FRAME]; @@ -242,7 +251,7 @@ void mbtask() #ifdef MBTCP handle_tcp(); #endif - #ifdef MBSERIAL + #ifdef MB_SERIAL_ACTIVE handle_serial(); #endif } @@ -397,7 +406,7 @@ void handle_tcp() } #endif -#ifdef MBSERIAL +#ifdef MB_SERIAL_ACTIVE // Inter-frame idle, in milliseconds, used ONLY to abandon a frame whose // remainder never arrives. Modbus RTU was defined for RS485, where bytes of a // frame are ~one character time apart (T1.5/T3.5, tens of microseconds at @@ -452,6 +461,10 @@ static int32_t mb_rtu_frame_len(const uint8_t *f, uint16_t n) return 10 + (int32_t)(((uint16_t)f[6] << 8) | f[7]); case MB_FC_DEBUG_GET_MD5: return 8; // [id][fc][endian:2][00:2][crc:2] + case MB_FC_DEBUG_GET_STATUS: + case MB_FC_DEBUG_GET_VERSION: + case MB_FC_DEBUG_GET_BOARD_ID: + return 4; // [id][fc][crc:2] default: return -1; // not one of our function codes } @@ -521,7 +534,7 @@ void handle_serial() // Standard FCs are validated by CRC (the arbiter that makes resync // trustworthy); a mismatch means corruption or misalignment, so we // slide one byte and retry instead of discarding the whole buffer. - if (mb_frame[1] != MB_FC_DEBUG_INFO && mb_frame[1] != MB_FC_DEBUG_SET && mb_frame[1] != MB_FC_DEBUG_GET && mb_frame[1] != MB_FC_DEBUG_GET_LIST && mb_frame[1] != MB_FC_DEBUG_GET_MD5) + if (mb_frame[1] != MB_FC_DEBUG_INFO && mb_frame[1] != MB_FC_DEBUG_SET && mb_frame[1] != MB_FC_DEBUG_GET && mb_frame[1] != MB_FC_DEBUG_GET_LIST && mb_frame[1] != MB_FC_DEBUG_GET_MD5 && mb_frame[1] != MB_FC_DEBUG_GET_STATUS && mb_frame[1] != MB_FC_DEBUG_GET_VERSION && mb_frame[1] != MB_FC_DEBUG_GET_BOARD_ID) { mb_frame_len = (uint16_t)expected; packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); @@ -594,13 +607,21 @@ void handle_serial() void process_mbpacket() { uint8_t fcode = mb_frame[1]; - // Standard Modbus fields — preserved for the non-debug FCs. +#ifdef MODBUS_ENABLED + // Standard Modbus fields — only used by the operation FCs, which are + // compiled out in debug-only builds (so guard to avoid unused-var warnings). uint16_t field1 = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; uint16_t field2 = (uint16_t)mb_frame[4] << 8 | (uint16_t)mb_frame[5]; +#endif void *endianness_check = &mb_frame[2]; switch (fcode) { +#ifdef MODBUS_ENABLED + // Standard Modbus operation FCs read/write the coil/register buffers, + // which only exist when full Modbus is enabled. In debug-only builds + // these cases are compiled out, so operation requests fall through to + // the default and get an ILLEGAL_FUNCTION exception. case MB_FC_WRITE_REG: //field1 = reg, field2 = value writeSingleRegister(field1, field2); @@ -640,6 +661,7 @@ void process_mbpacket() //field1 = startreg, field2 = numoutputs writeMultipleCoils(field1, field2, mb_frame[6]); break; +#endif // MODBUS_ENABLED case MB_FC_DEBUG_INFO: debugInfo(); @@ -679,6 +701,18 @@ void process_mbpacket() debugGetMd5(endianness_check); break; + case MB_FC_DEBUG_GET_STATUS: + debugGetStatus(); + break; + + case MB_FC_DEBUG_GET_VERSION: + debugGetVersion(); + break; + + case MB_FC_DEBUG_GET_BOARD_ID: + debugGetBoardId(); + break; + default: exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); } @@ -1330,7 +1364,7 @@ void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) uint16_t responseSize = 0; uint16_t lastReqIdx = 0; - #ifdef MBSERIAL + #ifdef MB_SERIAL_ACTIVE #define VARIDX_SIZE 20 #else #define VARIDX_SIZE 60 @@ -1439,6 +1473,77 @@ void debugGetMd5(void * /*endianness*/) mb_frame_len = md5_len + 5; } +// PDU request: [FC] +// PDU response: [FC, STATUS, running:u8, tick:u32 BE, uptime_ms:u32 BE] +// +// Lightweight liveness/diagnostic probe that does not require a full debug +// session. `running` is always 1 on baremetal (the PLC scan is unconditional); +// `tick` is the scan counter (same value the read FCs report), so a client can +// tell whether the PLC is actually cycling by watching it advance. `uptime_ms` +// is millis() since boot. +void debugGetStatus() +{ + uint32_t uptime = (uint32_t)millis(); + + mb_frame[1] = MB_FC_DEBUG_GET_STATUS; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = 1; // PLC scan is always running on baremetal + mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[7] = (uint8_t)(scan_counter & 0xFF); + mb_frame[8] = (uint8_t)((uptime >> 24) & 0xFF); + mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); + mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); + mb_frame[11] = (uint8_t)(uptime & 0xFF); + mb_frame_len = 12; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, version_ascii...] (no NUL terminator) +// +// Reports OPENPLC_RUNTIME_VERSION (defined in openplc_version.h). The editor +// reads the ASCII bytes up to the end of the frame. +void debugGetVersion() +{ + mb_frame[1] = MB_FC_DEBUG_GET_VERSION; + mb_frame[2] = MB_DEBUG_SUCCESS; + + const char ver[] = OPENPLC_RUNTIME_VERSION; + uint16_t i = 0; + for (i = 0; ver[i] != '\0'; i++) + { + if ((uint16_t)(3 + i) >= MAX_MB_FRAME) break; // never overrun the frame + mb_frame[3 + i] = (uint8_t)ver[i]; + } + mb_frame_len = 3 + i; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, id_len:u8, id_bytes...] +// +// Returns the unique hardware ID via ArduinoUniqueID. id_len is UniqueIDsize +// (architecture-dependent: AVR 9-10, ESP8266 4, ESP32 6, SAM/SAMD 16, STM32 +// 12, Teensy 8). On a core without support, id_len = 0 and no bytes follow. +void debugGetBoardId() +{ + mb_frame[1] = MB_FC_DEBUG_GET_BOARD_ID; + mb_frame[2] = MB_DEBUG_SUCCESS; + +#ifdef OPENPLC_HAS_UNIQUE_ID + uint8_t idLen = (uint8_t)UniqueIDsize; + // Clamp so [FC][STATUS][id_len][id_bytes...] always fits the frame. + if ((uint16_t)(4 + idLen) > MAX_MB_FRAME) idLen = (uint8_t)(MAX_MB_FRAME - 4); + mb_frame[3] = idLen; + for (uint8_t i = 0; i < idLen; i++) + mb_frame[4 + i] = UniqueID[i]; + mb_frame_len = 4 + idLen; +#else + mb_frame[3] = 0; // no unique-id support on this core + mb_frame_len = 4; +#endif +} + uint16_t calcCrc() { uint8_t CRCHi = 0xFF, CRCLo = 0x0FF, Index; diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 47236126d..5591eb387 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -8,6 +8,31 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #include #include "defines.h" +#include "openplc_version.h" + +// Serial transport is active when full Modbus RTU (MBSERIAL) is enabled OR the +// always-on debugger (DEBUGGER_ENABLED) needs the serial port without the rest +// of Modbus. This gate guards the serial RX/framing code so the debugger works +// over serial even when no Modbus operation buffers (coils/holding/etc.) are +// allocated. +#if defined(MBSERIAL) || defined(DEBUGGER_ENABLED) + #define MB_SERIAL_ACTIVE +#endif + +// Default serial config for the always-on debugger when full Modbus RTU +// (MBSERIAL_*) is NOT configured. Override any of these in defines.h to change +// the port/baud/slave the debugger listens on. +#if defined(DEBUGGER_ENABLED) && !defined(MBSERIAL) + #ifndef DEBUG_IFACE + #define DEBUG_IFACE Serial + #endif + #ifndef DEBUG_BAUD + #define DEBUG_BAUD 115200 + #endif + #ifndef DEBUG_SLAVE + #define DEBUG_SLAVE 1 + #endif +#endif #ifndef bitRead #define bitRead(value, bit) (((value) >> (bit)) & 0x01) @@ -111,6 +136,9 @@ enum { MB_FC_DEBUG_GET = 0x43, // Debug get trace (read variables) MB_FC_DEBUG_GET_LIST = 0x44, // Debug get trace list (read list of variables) MB_FC_DEBUG_GET_MD5 = 0x45, // Debug get current program MD5 + MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) + MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version + MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID }; //Exception Codes @@ -161,7 +189,7 @@ void mbtask(); #ifdef MBTCP void handle_tcp(); #endif -#ifdef MBSERIAL +#ifdef MB_SERIAL_ACTIVE void handle_serial(); #endif void process_mbpacket(); @@ -185,6 +213,10 @@ void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx); void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray); void debugGetMd5(void *endianness); +// Always-on debugger extras — served even without full Modbus (DEBUGGER_ENABLED). +void debugGetStatus(void); +void debugGetVersion(void); +void debugGetBoardId(void); /* Table of CRC values for high-order byte */ diff --git a/resources/sources/Baremetal/openplc_version.h b/resources/sources/Baremetal/openplc_version.h new file mode 100644 index 000000000..8023a9080 --- /dev/null +++ b/resources/sources/Baremetal/openplc_version.h @@ -0,0 +1,19 @@ +/* +openplc_version.h - OpenPLC runtime/firmware version +Copyright (C) 2022 OpenPLC - Thiago Alves + +Single source of truth for the firmware version reported by the always-on +debugger (Modbus FC 0x47, DEBUG_GET_VERSION). This is a property of the +firmware source tree, NOT the editor application version, so it is defined +here rather than injected by the editor at compile time. Bump manually when +the firmware runtime evolves. +*/ + +#ifndef OPENPLC_VERSION_H +#define OPENPLC_VERSION_H + +#ifndef OPENPLC_RUNTIME_VERSION + #define OPENPLC_RUNTIME_VERSION "4.2.7" +#endif + +#endif // OPENPLC_VERSION_H diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 6a3ae3cdb..74f9bf34b 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -195,6 +195,10 @@ class CompilerModule { 'ArduinoJson', 'Arduino_MachineControl', 'ArduinoMqttClient', + // Backs the always-on debugger's DEBUG_GET_BOARD_ID (FC 0x48). ModbusSlave.cpp + // includes unconditionally (not behind a USE_*_BLOCK gate), + // so the lib must be installed for every Arduino build. + 'ArduinoUniqueID', 'AVR_PWM', 'CAN', 'CONTROLLINO', diff --git a/src/backend/editor/modbus/modbus-client.ts b/src/backend/editor/modbus/modbus-client.ts index 5074b80bf..fba1f2508 100644 --- a/src/backend/editor/modbus/modbus-client.ts +++ b/src/backend/editor/modbus/modbus-client.ts @@ -9,6 +9,9 @@ export enum ModbusFunctionCode { DEBUG_GET = 0x43, DEBUG_GET_LIST = 0x44, DEBUG_GET_MD5 = 0x45, + DEBUG_GET_STATUS = 0x46, + DEBUG_GET_VERSION = 0x47, + DEBUG_GET_BOARD_ID = 0x48, } export enum ModbusDebugResponse { diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index 0ef0902b3..05a9b75ce 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -153,6 +153,43 @@ describe('generateDefinesContent — simulator comms block', () => { }) }) +describe('generateDefinesContent — Debugger block (always-on debug)', () => { + it('emits DEBUGGER_ENABLED for a baremetal arduino-cli target with no Modbus', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('//Debugger\n#define DEBUGGER_ENABLED\n') + }) + + it('emits DEBUGGER_ENABLED when the Modbus screen is present but disabled', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + vppModbusState: { modbus_rtu: { enabled: false }, modbus_tcp: { enabled: false } }, + }) + expect(out).toContain('#define DEBUGGER_ENABLED') + }) + + it('does NOT emit DEBUGGER_ENABLED when full Modbus is enabled (debugger rides Modbus)', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '115200', rtu_slave_id: 1 }, + }, + }) + expect(out).not.toContain('DEBUGGER_ENABLED') + }) + + it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'simulator' }) + expect(out).not.toContain('DEBUGGER_ENABLED') + }) + + it('does NOT emit DEBUGGER_ENABLED for openplc-compiler runtimes', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'openplc-compiler' }) + expect(out).not.toContain('DEBUGGER_ENABLED') + }) +}) + describe('generateDefinesContent — IO Config (pin masks)', () => { it('emits empty pin masks when devicePinMapping is empty', () => { const out = generateDefinesContent(EMPTY_INPUTS) @@ -358,6 +395,10 @@ describe('generateDefinesContent — full output snapshot', () => { '//Program MD5', '#define PROGRAM_MD5 "ffffffffffffffffffffffffffffffff"', '', + '//Debugger', + '#define DEBUGGER_ENABLED', + '', + '', '//IO Config', '#define PINMASK_DIN ', '#define PINMASK_AIN 4', diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index ecddca33e..a5e0b55c2 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -145,6 +145,7 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { // Runtime-v4 / runtime-v3 targets route Modbus config through // `conf/modbus_slave.json` in the upload bundle and emit no // macros here. + let modbusEnabled = false if (boardRuntime === 'simulator') { DEFINES_CONTENT += '//Comms Configuration\n' DEFINES_CONTENT += '#define SIMULATOR_MODE\n' @@ -154,14 +155,31 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += '#define MBSERIAL\n' DEFINES_CONTENT += '#define MODBUS_ENABLED\n' DEFINES_CONTENT += `\n\n` + modbusEnabled = true } else if (boardRuntime !== 'openplc-compiler' && vppModbusState) { const modbusBlock = generateModbusDefines(vppModbusState) if (modbusBlock.length > 0) { DEFINES_CONTENT += modbusBlock DEFINES_CONTENT += '\n\n' + modbusEnabled = true } } + // 4b. Debugger — always-on debug over serial for baremetal Arduino targets. + // Emitted only when full Modbus is NOT active: with Modbus off, this is + // the gate that brings up the serial port and the debug function codes + // (0x41-0x48) WITHOUT allocating any operation buffers (coils/holding/ + // etc.), saving SRAM on small boards. When Modbus IS active the debugger + // already rides Modbus's own transport, so emitting DEBUGGER_ENABLED then + // would be redundant (and, in a TCP-only Modbus build, would leave the + // serial port uninitialised). Simulator gets MODBUS_ENABLED above; + // openplc-compiler runtimes don't use this firmware at all. + if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler' && !modbusEnabled) { + DEFINES_CONTENT += '//Debugger\n' + DEFINES_CONTENT += '#define DEBUGGER_ENABLED\n' + DEFINES_CONTENT += `\n\n` + } + // 5. IO Config — derived from devicePinMapping. Pin order is // the iteration order of the input array; callers are // expected to have sorted by address. diff --git a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts index 1bb6c2864..385afa244 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts @@ -12,11 +12,17 @@ if (typeof globalThis.TextDecoder === 'undefined') { import { ModbusDebugResponse, ModbusFunctionCode } from '../../simulator/types' import { + buildGetBoardIdRequest, buildGetListRequest, buildGetMd5Request, + buildGetStatusRequest, + buildGetVersionRequest, buildSetVariableRequest, + parseGetBoardIdResponse, parseGetListResponse, parseGetMd5Response, + parseGetStatusResponse, + parseGetVersionResponse, parseSetVariableResponse, responseFunctionCode, } from '../modbus-pdu' @@ -190,6 +196,185 @@ describe('buildSetVariableRequest / parseSetVariableResponse', () => { }) }) +describe('buildGetStatusRequest / parseGetStatusResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetStatusRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_STATUS) + }) + + it('parses running / tick / uptime on success', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_STATUS, + ModbusDebugResponse.SUCCESS, + 0x01, // running = true + 0x00, + 0x00, + 0x00, + 0x2a, // tick = 42 + 0x00, + 0x00, + 0x01, + 0x00, // uptime = 256 + ]) + const result = parseGetStatusResponse(buf) + expect(result).toEqual({ success: true, running: true, tick: 42, uptimeMs: 256 }) + }) + + it('reports running=false when the flag byte is zero', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_STATUS, + ModbusDebugResponse.SUCCESS, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ]) + expect(parseGetStatusResponse(buf).running).toBe(false) + }) + + it('flags too-short buffer', () => { + const result = parseGetStatusResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetStatusResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetStatusResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + }) + + it('flags an incomplete success payload', () => { + const result = parseGetStatusResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.SUCCESS, 0x01]), + ) + expect(result.success).toBe(false) + expect(result.error).toMatch(/Incomplete/) + }) +}) + +describe('buildGetVersionRequest / parseGetVersionResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetVersionRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_VERSION) + }) + + it('parses the ASCII version string on success', () => { + const ver = new TextEnc().encode('4.2.7') + const buf = new Uint8Array(2 + ver.length) + buf[0] = ModbusFunctionCode.DEBUG_GET_VERSION + buf[1] = ModbusDebugResponse.SUCCESS + buf.set(ver, 2) + expect(parseGetVersionResponse(buf)).toEqual({ success: true, version: '4.2.7' }) + }) + + it('strips a trailing NUL terminator', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION, ModbusDebugResponse.SUCCESS, 0x31, 0x2e, 0x30, 0x00]) + expect(parseGetVersionResponse(buf).version).toBe('1.0') + }) + + it('flags too-short buffer', () => { + const result = parseGetVersionResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetVersionResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetVersionResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION, ModbusDebugResponse.ERROR_OUT_OF_MEMORY]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_MEMORY') + }) +}) + +describe('buildGetBoardIdRequest / parseGetBoardIdResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetBoardIdRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_BOARD_ID) + }) + + it('parses id bytes and hex on success', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_BOARD_ID, + ModbusDebugResponse.SUCCESS, + 0x03, // id_len = 3 + 0x0a, + 0xbc, + 0x01, + ]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(true) + expect(Array.from(result.boardId!)).toEqual([0x0a, 0xbc, 0x01]) + expect(result.boardIdHex).toBe('0abc01') + }) + + it('handles id_len = 0 (unsupported core) as success with empty id', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS, 0x00]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(true) + expect(result.boardIdHex).toBe('') + expect(Array.from(result.boardId!)).toEqual([]) + }) + + it('flags too-short buffer', () => { + const result = parseGetBoardIdResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetBoardIdResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetBoardIdResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + }) + + it('flags a missing id_len byte', () => { + const result = parseGetBoardIdResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS]), + ) + expect(result.success).toBe(false) + expect(result.error).toMatch(/at least 3/) + }) + + it('flags truncated id bytes', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(false) + expect(result.error).toMatch(/Incomplete board-id data/) + }) +}) + describe('responseFunctionCode', () => { it('returns the first byte', () => { expect(responseFunctionCode(new Uint8Array([0x45, 0x00]))).toBe(0x45) diff --git a/src/backend/shared/debug/modbus-pdu.ts b/src/backend/shared/debug/modbus-pdu.ts index 1c7d60e69..5fcd2856b 100644 --- a/src/backend/shared/debug/modbus-pdu.ts +++ b/src/backend/shared/debug/modbus-pdu.ts @@ -42,7 +42,14 @@ import { detectTargetEndian, type TargetEndian } from '../../../frontend/utils/endian' import { ModbusDebugResponse, ModbusFunctionCode } from '../simulator/types' -import type { DebugSetResult, DebugTransportResult, Md5ProbeResult } from './types' +import type { + DebugBoardIdResult, + DebugSetResult, + DebugStatusResult, + DebugTransportResult, + DebugVersionResult, + Md5ProbeResult, +} from './types' // --------------------------------------------------------------------------- // Uint8Array helpers — host-endian-agnostic, no typed-array views on wire data. @@ -135,6 +142,27 @@ export function buildSetVariableRequest(index: number, force: boolean, valueBuff return buf } +// Always-on debugger extras. Each is a bare [FC] PDU — no payload — mirroring +// the firmware's `mb_rtu_frame_len` entry of 4 (id + FC + 2 CRC bytes). + +export function buildGetStatusRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_STATUS) + return buf +} + +export function buildGetVersionRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_VERSION) + return buf +} + +export function buildGetBoardIdRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_BOARD_ID) + return buf +} + // --------------------------------------------------------------------------- // Parse responses // --------------------------------------------------------------------------- @@ -248,6 +276,100 @@ export function parseSetVariableResponse(data: Uint8Array): DebugSetResult { return { success: true } } +/** + * Parse a status response (FC 0x46). + * Layout: `[FC][status][running:u8][tick:u32BE][uptime:u32BE]` (11 PDU bytes). + */ +export function parseGetStatusResponse(data: Uint8Array): DebugStatusResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_STATUS) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + if (data.length < 11) { + return { success: false, error: `Incomplete status response (${data.length} bytes, expected 11)` } + } + + return { + success: true, + running: readU8(data, 2) !== 0, + tick: readU32BE(data, 3), + uptimeMs: readU32BE(data, 7), + } +} + +/** + * Parse a version response (FC 0x47). + * Layout: `[FC][status][version ASCII...]` (no NUL terminator on the wire). + */ +export function parseGetVersionResponse(data: Uint8Array): DebugVersionResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_VERSION) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + const version = new TextDecoder('utf-8').decode(data.subarray(2)).replace(/\0+$/, '').trim() + return { success: true, version } +} + +/** + * Parse a board-id response (FC 0x48). + * Layout: `[FC][status][id_len:u8][id_bytes...]`. `id_len === 0` means the + * target has no unique-id support — success with an empty id. + */ +export function parseGetBoardIdResponse(data: Uint8Array): DebugBoardIdResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_BOARD_ID) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + if (data.length < 3) { + return { success: false, error: `Incomplete board-id response (${data.length} bytes, expected at least 3)` } + } + + const idLen = readU8(data, 2) + if (data.length < 3 + idLen) { + return { + success: false, + error: `Incomplete board-id data (expected ${idLen} bytes, got ${data.length - 3})`, + } + } + + const boardId = data.slice(3, 3 + idLen) + const boardIdHex = Array.from(boardId, (b) => b.toString(16).padStart(2, '0')).join('') + return { success: true, boardId, boardIdHex } +} + /** * Extract the function code from a Modbus PDU response. * Returns `undefined` if the buffer is empty. diff --git a/src/backend/shared/debug/types.ts b/src/backend/shared/debug/types.ts index 8b2ebe373..05cd678ca 100644 --- a/src/backend/shared/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -23,6 +23,38 @@ export interface DebugSetResult { error?: string } +/** + * Result of the always-on debugger status probe (FC 0x46). `running` is the + * PLC scan liveness flag, `tick` the scan counter (advances each cycle), and + * `uptimeMs` the milliseconds since the board booted. + */ +export interface DebugStatusResult { + success: boolean + running?: boolean + tick?: number + uptimeMs?: number + error?: string +} + +/** Result of the runtime version probe (FC 0x47) — ASCII version string. */ +export interface DebugVersionResult { + success: boolean + version?: string + error?: string +} + +/** + * Result of the board-id probe (FC 0x48). `boardId` is the raw unique-id bytes + * (empty when the target has no unique-id support); `boardIdHex` is the same + * bytes as a lowercase hex string for display. + */ +export interface DebugBoardIdResult { + success: boolean + boardId?: Uint8Array + boardIdHex?: string + error?: string +} + /** * Result of an MD5-probe call. The `md5` is the runtime's program hash; * `targetEndian` is the byte order detected from the 2-byte sentinel the diff --git a/src/backend/shared/simulator/__tests__/debug-e2e.test.ts b/src/backend/shared/simulator/__tests__/debug-e2e.test.ts index 5bb6cfb29..a18b18eee 100644 --- a/src/backend/shared/simulator/__tests__/debug-e2e.test.ts +++ b/src/backend/shared/simulator/__tests__/debug-e2e.test.ts @@ -85,4 +85,34 @@ describeIfHex('Phase 4 debugger end-to-end (avr8js + ModbusRtuClient)', () => { // non-deterministic and this test already validated at the SET layer // that the protocol accepts the unforce request. }, 30000) + + it('FC 0x46 DEBUG_GET_STATUS reports the PLC running with an advancing tick', async () => { + const first = await client.getStatus() + expect(first.success).toBe(true) + expect(first.running).toBe(true) + expect(typeof first.tick).toBe('number') + expect(typeof first.uptimeMs).toBe('number') + + // Let a few scan cycles run — the scan counter must advance. + await new Promise((r) => setTimeout(r, 200)) + const second = await client.getStatus() + expect(second.success).toBe(true) + expect(second.tick!).toBeGreaterThan(first.tick!) + }, 30000) + + it('FC 0x47 DEBUG_GET_VERSION returns the runtime version string', async () => { + const result = await client.getVersion() + expect(result.success).toBe(true) + // OPENPLC_RUNTIME_VERSION is a dotted version like "4.2.7". + expect(result.version).toMatch(/^\d+\.\d+\.\d+/) + }, 30000) + + it('FC 0x48 DEBUG_GET_BOARD_ID returns the AVR unique id (9 bytes on ATmega2560)', async () => { + const result = await client.getBoardId() + expect(result.success).toBe(true) + // ArduinoUniqueID reports 9 bytes on AVR (10 on ATmega328PB). The + // emulated ATmega2560 yields a non-empty id; assert it round-trips. + expect(result.boardId!.length).toBeGreaterThan(0) + expect(result.boardIdHex).toMatch(/^[0-9a-f]+$/) + }, 30000) }) diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 462c8c1b4..56ff8af21 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -551,6 +551,209 @@ describe('ModbusRtuClient', () => { }) }) + // ----------------------------------------------------------------------- + // getStatus (FC 0x46) + // ----------------------------------------------------------------------- + describe('getStatus', () => { + function statusPayload(running: number, tick: number, uptime: number): Uint8Array { + const payload = new Uint8Array(10) + payload[0] = ModbusDebugResponse.SUCCESS + payload[1] = running + payload[2] = (tick >>> 24) & 0xff + payload[3] = (tick >>> 16) & 0xff + payload[4] = (tick >>> 8) & 0xff + payload[5] = tick & 0xff + payload[6] = (uptime >>> 24) & 0xff + payload[7] = (uptime >>> 16) & 0xff + payload[8] = (uptime >>> 8) & 0xff + payload[9] = uptime & 0xff + return payload + } + + it('returns running / tick / uptime on success', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, statusPayload(1, 42, 256))) + const result = await client.getStatus() + expect(result).toEqual({ success: true, running: true, tick: 42, uptimeMs: 256 }) + }) + + it('reports running=false when the flag byte is zero', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, statusPayload(0, 1, 1))) + const result = await client.getStatus() + expect(result.running).toBe(false) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS]))) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([0x99]))) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on incomplete success payload', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([ModbusDebugResponse.SUCCESS, 1]))) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('Incomplete status response') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_STATUS]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + + // ----------------------------------------------------------------------- + // getVersion (FC 0x47) + // ----------------------------------------------------------------------- + describe('getVersion', () => { + it('returns the ASCII version string on success', async () => { + await connectClient() + const ver = new TextEncoder().encode('4.2.7') + const payload = new Uint8Array(1 + ver.length) + payload[0] = ModbusDebugResponse.SUCCESS + payload.set(ver, 1) + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_VERSION, payload)) + const result = await client.getVersion() + expect(result).toEqual({ success: true, version: '4.2.7' }) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS]))) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_VERSION, new Uint8Array([0x99]))) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_VERSION]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + + // ----------------------------------------------------------------------- + // getBoardId (FC 0x48) + // ----------------------------------------------------------------------- + describe('getBoardId', () => { + it('returns id bytes and hex on success', async () => { + await connectClient() + const payload = new Uint8Array([ModbusDebugResponse.SUCCESS, 0x03, 0x0a, 0xbc, 0x01]) + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, payload)) + const result = await client.getBoardId() + expect(result.success).toBe(true) + expect(Array.from(result.boardId!)).toEqual([0x0a, 0xbc, 0x01]) + expect(result.boardIdHex).toBe('0abc01') + }) + + it('handles id_len = 0 (unsupported core) as success with empty id', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00]))) + const result = await client.getBoardId() + expect(result.success).toBe(true) + expect(result.boardIdHex).toBe('') + expect(Array.from(result.boardId!)).toEqual([]) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00]))) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([0x99, 0x00]))) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on incomplete id data', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b])), + ) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('Incomplete board-id data') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS]) + const crc = calculateCrc(frame) + const full = new Uint8Array(frame.length + 2) + full.set(frame, 0) + full[frame.length] = (crc >>> 8) & 0xff + full[frame.length + 1] = crc & 0xff + autoRespond(full) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + // ----------------------------------------------------------------------- // sendRequestImpl edge cases // ----------------------------------------------------------------------- @@ -684,6 +887,51 @@ describe('ModbusRtuClient', () => { expect(result.success).toBe(false) expect(result.error).toBe('non-error string') }) + + it('getStatus handles response too short (<9 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('getStatus handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getStatus() + expect(result.error).toBe('non-error string') + }) + + it('getVersion handles response too short (<9 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('getVersion handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getVersion() + expect(result.error).toBe('non-error string') + }) + + it('getBoardId handles response too short (<10 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('getBoardId handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getBoardId() + expect(result.error).toBe('non-error string') + }) }) // ----------------------------------------------------------------------- diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts index 0e628d582..8d712af56 100644 --- a/src/backend/shared/simulator/modbus-rtu-client.ts +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -462,4 +462,110 @@ export class ModbusRtuClient { return { success: false, error: error instanceof Error ? error.message : String(error) } } } + + // ------------------------------------------------------------------------- + // Always-on debugger extras (FC 0x46/0x47/0x48). Each is a bare-FC request. + // Response offsets account for the 6-byte TCP-compat padding sendRequestImpl + // prepends: slaveId@6, FC@7, status@8, payload@9+. + // ------------------------------------------------------------------------- + + async getStatus(): Promise<{ + success: boolean + running?: boolean + tick?: number + uptimeMs?: number + error?: string + }> { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_STATUS, allocBytes(0)) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_STATUS as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + if (response.length < 18) { + return { success: false, error: `Incomplete status response (${response.length} bytes, expected at least 18)` } + } + + return { + success: true, + running: readUint8(response, 9) !== 0, + tick: readUint32BE(response, 10), + uptimeMs: readUint32BE(response, 14), + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async getVersion(): Promise<{ success: boolean; version?: string; error?: string }> { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_VERSION, allocBytes(0)) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_VERSION as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + const version = new TextDecoder().decode(response.slice(9)).replace(/\0+$/, '').trim() + return { success: true, version } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async getBoardId(): Promise<{ success: boolean; boardId?: Uint8Array; boardIdHex?: string; error?: string }> { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_BOARD_ID, allocBytes(0)) + const response = await this.sendRequest(request) + + if (response.length < 10) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 10)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_BOARD_ID as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + const idLen = readUint8(response, 9) + if (response.length < 10 + idLen) { + return { + success: false, + error: `Incomplete board-id data (expected ${idLen} bytes, got ${response.length - 10})`, + } + } + + const boardId = response.slice(10, 10 + idLen) + const boardIdHex = Array.from(boardId, (b) => b.toString(16).padStart(2, '0')).join('') + return { success: true, boardId, boardIdHex } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } } diff --git a/src/backend/shared/simulator/types.ts b/src/backend/shared/simulator/types.ts index a2ac377f6..006f4651a 100644 --- a/src/backend/shared/simulator/types.ts +++ b/src/backend/shared/simulator/types.ts @@ -4,6 +4,9 @@ export enum ModbusFunctionCode { DEBUG_GET = 0x43, DEBUG_GET_LIST = 0x44, DEBUG_GET_MD5 = 0x45, + DEBUG_GET_STATUS = 0x46, + DEBUG_GET_VERSION = 0x47, + DEBUG_GET_BOARD_ID = 0x48, } export enum ModbusDebugResponse { From 232da71e4bd92db2a11fc5af86bbf9167f2dd53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 11:40:45 +0200 Subject: [PATCH 12/79] feat(vpp): resolve VPP select options dynamically from board data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (serial-network-modbus split): let a VPP screen `select` field source its options from per-board context via `optionsRef` (e.g. "board.serialPorts"), so a shared screen adapts to each board — the Modbus RTU serial-port picker now lists only the UARTs the board actually exposes instead of a static Serial/ Serial1/2/3 list. - BoardInfo + PackageManifest device gain serialPorts/defaultSerial; the hardware module forwards them from the manifest onto the board info. - utils/vpp/field-options: resolveFieldOptions helper (optionsRef wins when it resolves to a non-empty array, else falls back to static options). 100% covered. - form-layout: select uses resolveFieldOptions with the current board as context. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/hardware/hardware-module.ts | 2 + .../vendor-screen/layouts/form-layout.tsx | 13 ++++- .../utils/vpp/__tests__/field-options.test.ts | 46 ++++++++++++++++++ src/frontend/utils/vpp/field-options.ts | 48 +++++++++++++++++++ src/middleware/shared/ports/types.ts | 19 ++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/frontend/utils/vpp/__tests__/field-options.test.ts create mode 100644 src/frontend/utils/vpp/field-options.ts diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index ea312170a..ea7889e39 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -358,6 +358,8 @@ class HardwareModule { } : null, }, + ...(device.serialPorts ? { serialPorts: device.serialPorts } : {}), + ...(device.defaultSerial ? { defaultSerial: device.defaultSerial } : {}), ...(device.debug ? { debug: device.debug } : {}), }) } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx index 99071c2eb..bc920f2bf 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx @@ -4,6 +4,7 @@ import { ToggleSwitch } from '@root/frontend/components/_atoms/toggle-switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@root/frontend/components/_atoms/tooltip' import { useOpenPLCStore } from '@root/frontend/store' import { evalVisible, type VisibleCondition } from '@root/frontend/utils/vpp/eval-visible' +import { resolveFieldOptions } from '@root/frontend/utils/vpp/field-options' import { getSectionPersistenceKey } from '@root/frontend/utils/vpp/persistence-keys' import type { ScreenSection } from '../index' @@ -19,6 +20,10 @@ type FieldDef = { unit?: string help?: string options?: string[] | Array<{ value: string; label: string }> + // Dynamic option source (VPP screen schema): a dotted path resolved against + // per-board context, e.g. "board.serialPorts". Wins over `options` when it + // resolves to a non-empty array; otherwise `options` is the fallback. + optionsRef?: string // Honored by text-like inputs (text, password, ip-address, mac-address). // Mirrors the VPP screen schema's optional field props — empty strings // are skipped so HTML5 placeholder/maxLength/pattern stay unset when @@ -77,6 +82,10 @@ function FormLayout({ section }: FormLayoutProps) { const vendorScreenData = useOpenPLCStore((s) => s.deviceDefinitions.configuration.vendorScreenData) const setVendorScreenData = useOpenPLCStore((s) => s.deviceActions.setVendorScreenData) + // Board context for dynamic `optionsRef` resolution (e.g. the Modbus RTU + // serial-port picker reading `board.serialPorts`). + const deviceBoard = useOpenPLCStore((s) => s.deviceDefinitions.configuration.deviceBoard) + const currentBoardInfo = useOpenPLCStore((s) => s.deviceAvailableOptions.availableBoards.get(deviceBoard)) // Single-source-of-truth for the per-section storage key — see // `getSectionPersistenceKey` in ../index.tsx. Every layout that // persists must derive its key through this helper so the @@ -162,7 +171,9 @@ function FormLayout({ section }: FormLayoutProps) { align='center' side='bottom' > - {(field.options ?? []).map((opt) => { + {resolveFieldOptions(field, { + board: currentBoardInfo as Record | undefined, + }).map((opt) => { const value = typeof opt === 'string' ? opt : opt.value const label = typeof opt === 'string' ? opt : opt.label return ( diff --git a/src/frontend/utils/vpp/__tests__/field-options.test.ts b/src/frontend/utils/vpp/__tests__/field-options.test.ts new file mode 100644 index 000000000..5ca4b9381 --- /dev/null +++ b/src/frontend/utils/vpp/__tests__/field-options.test.ts @@ -0,0 +1,46 @@ +import { resolveFieldOptions } from '../field-options' + +describe('resolveFieldOptions', () => { + it('returns static options when there is no optionsRef', () => { + expect(resolveFieldOptions({ options: ['a', 'b'] }, { board: undefined })).toEqual(['a', 'b']) + }) + + it('returns an empty array when neither options nor optionsRef are set', () => { + expect(resolveFieldOptions({}, { board: undefined })).toEqual([]) + }) + + it('resolves a dynamic optionsRef from board context', () => { + expect( + resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: { serialPorts: ['Serial', 'Serial1'] } }), + ).toEqual(['Serial', 'Serial1']) + }) + + it('falls back to static options when optionsRef resolves to undefined', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: {} })).toEqual(['Serial']) + }) + + it('falls back to static options when the board is absent', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: undefined })).toEqual(['Serial']) + }) + + it('falls back to static options when optionsRef resolves to an empty array', () => { + expect( + resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: { serialPorts: [] } }), + ).toEqual(['Serial']) + }) + + it('preserves object-shaped options ({ value, label })', () => { + const opts = [{ value: 'a', label: 'A' }] + expect(resolveFieldOptions({ options: opts }, { board: undefined })).toEqual(opts) + }) + + it('filters out non-option entries resolved from optionsRef', () => { + expect( + resolveFieldOptions({ optionsRef: 'board.serialPorts' }, { board: { serialPorts: ['Serial', 42, null] } }), + ).toEqual(['Serial']) + }) + + it('returns empty array fallback when optionsRef yields only invalid entries and no static options', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts' }, { board: { serialPorts: [42] } })).toEqual([]) + }) +}) diff --git a/src/frontend/utils/vpp/field-options.ts b/src/frontend/utils/vpp/field-options.ts new file mode 100644 index 000000000..2fb38eda2 --- /dev/null +++ b/src/frontend/utils/vpp/field-options.ts @@ -0,0 +1,48 @@ +/** + * Resolve the option list for a VPP screen `select` field. + * + * A field may declare static `options` and/or a dynamic `optionsRef` — a dotted + * path (e.g. `"board.serialPorts"`) resolved against per-board context so the + * same shared screen adapts to each board (the Modbus RTU serial-port picker + * lists only the UARTs the board actually exposes). When `optionsRef` resolves + * to a non-empty array it wins; otherwise the static `options` are the fallback, + * so a board that doesn't declare the referenced data still renders sensibly. + * + * Pure — no store, no I/O. + */ + +export type FieldOption = string | { value: string; label: string } + +export interface FieldOptionSource { + options?: FieldOption[] + optionsRef?: string +} + +/** Walk a dotted path (`a.b.c`) into a context object; undefined on any miss. */ +function lookupPath(path: string, context: Record): unknown { + let cursor: unknown = context + for (const part of path.split('.')) { + if (cursor === null || cursor === undefined || typeof cursor !== 'object') return undefined + cursor = (cursor as Record)[part] + } + return cursor +} + +function isFieldOption(value: unknown): value is FieldOption { + return typeof value === 'string' || (typeof value === 'object' && value !== null && 'value' in value) +} + +export function resolveFieldOptions( + field: FieldOptionSource, + context: { board?: Record | undefined }, +): FieldOption[] { + if (field.optionsRef) { + const resolved = lookupPath(field.optionsRef, context as Record) + if (Array.isArray(resolved)) { + const opts = resolved.filter(isFieldOption) + if (opts.length > 0) return opts + } + // optionsRef present but unresolved / empty → fall back to static options. + } + return field.options ?? [] +} diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index f795de683..090044a9f 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -627,6 +627,18 @@ export interface BoardInfo { * declare it. */ platformOptions?: PlatformOption[] + /** + * Hardware serial ports this board exposes (e.g. `['Serial', 'Serial1']`), + * mirrored from the VPP manifest device's `serialPorts`. Consumed by VPP + * screen `select` fields via `optionsRef: 'board.serialPorts'` (the Modbus + * RTU port picker) and by the always-on serial/debugger. Absent → the editor + * assumes a single `Serial`. + */ + serialPorts?: string[] + /** Name of the default serial port (usually the USB CDC port) where the + * debugger runs. Mirrors the manifest device's `defaultSerial`. Absent → + * `Serial`. */ + defaultSerial?: string /** * Declarative debug-channel resolver spec carried through from the * source catalog (hals.json or VPP manifest). Consumed by @@ -786,6 +798,13 @@ export interface PackageManifest { } } screens?: Record + /** Hardware serial ports this device exposes (e.g. `['Serial', 'Serial1']`). + * Surfaced onto `BoardInfo.serialPorts` and consumed by VPP screen + * `select` fields via `optionsRef: 'board.serialPorts'`. */ + serialPorts?: string[] + /** Name of the default serial port (usually the USB CDC port). Surfaced onto + * `BoardInfo.defaultSerial`. Absent → `Serial`. */ + defaultSerial?: string /** Declarative debug-channel resolver spec, consumed by * `backend/shared/hardware/debug-spec.ts`. Same shape as * the `debug` field on built-in hals.json entries — the From 631c895ca00ddd8e46fc66d2f7ceb23cd7565b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 12:03:16 +0200 Subject: [PATCH 13/79] feat(compile): generate defines for the serial/network/modbus split Phase 2: emit an always-on serial debugger and read Modbus config from the new sections. - generate-defines: the Debugger block is now unconditional for baremetal Arduino targets, emitting DEBUG_IFACE (default serial) and DEBUG_BAUD (from the Serial section). The old "only when Modbus is off" gate is gone. - modbus-defines: RTU reads serial_port (legacy rtu_interface fallback) and takes its baud from the Serial section on the default port or its own baud on a secondary port; emits MBSERIAL_SHARES_DEBUG_SERIAL when it runs on the default port so the firmware begins the port once. TCP reads network config from the Network section (legacy modbus_tcp.* fallback). New optional defaultSerial arg. - compiler-module: feed the serial/network sections into vppModbusState. Backward-tolerant: pre-migration projects (legacy modbus_rtu/modbus_tcp shape) still generate correct defines. 55 tests pass, 100% stmts/lines/functions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/compiler/compiler-module.ts | 2 + .../__tests__/generate-defines.test.ts | 29 +++++- .../compile/__tests__/modbus-defines.test.ts | 50 ++++++++++ .../shared/compile/steps/generate-defines.ts | 33 ++++--- .../shared/compile/steps/modbus-defines.ts | 97 +++++++++++++------ 5 files changed, 163 insertions(+), 48 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 74f9bf34b..a086d6eb4 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2618,6 +2618,8 @@ class CompilerModule { const deviceConfig = await CompilerModule.readJSONFile(devicesConfigurationFilePath) const vendorScreenData = deviceConfig.vendorScreenData ?? {} vppModbusState = { + serial: vendorScreenData['serial'] as VppModbusScreenState['serial'], + network: vendorScreenData['network'] as VppModbusScreenState['network'], modbus_rtu: vendorScreenData['modbus_rtu'] as VppModbusScreenState['modbus_rtu'], modbus_tcp: vendorScreenData['modbus_tcp'] as VppModbusScreenState['modbus_tcp'], } diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index 05a9b75ce..a7b7b750c 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -168,15 +168,36 @@ describe('generateDefinesContent — Debugger block (always-on debug)', () => { expect(out).toContain('#define DEBUGGER_ENABLED') }) - it('does NOT emit DEBUGGER_ENABLED when full Modbus is enabled (debugger rides Modbus)', () => { + it('emits DEBUGGER_ENABLED even when full Modbus is enabled (always-on serial debugger)', () => { const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli', vppModbusState: { - modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '115200', rtu_slave_id: 1 }, + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial', rtu_slave_id: 1 }, }, + defaultSerial: 'Serial', }) - expect(out).not.toContain('DEBUGGER_ENABLED') + expect(out).toContain('#define DEBUGGER_ENABLED') + // RTU on the default serial → shares the debugger's port (single begin()). + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('emits DEBUG_IFACE from defaultSerial and DEBUG_BAUD from the Serial section', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { serial: { baud_rate: '9600' } }, + }) + expect(out).toContain('#define DEBUG_IFACE Serial') + expect(out).toContain('#define DEBUG_BAUD 9600') + }) + + it('falls back to DEBUG_IFACE Serial and DEBUG_BAUD 115200 when unset', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('#define DEBUG_IFACE Serial') + expect(out).toContain('#define DEBUG_BAUD 115200') }) it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { @@ -397,6 +418,8 @@ describe('generateDefinesContent — full output snapshot', () => { '', '//Debugger', '#define DEBUGGER_ENABLED', + '#define DEBUG_IFACE Serial', + '#define DEBUG_BAUD 115200', '', '', '//IO Config', diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index a16fe762e..72c8155f6 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -22,6 +22,7 @@ describe('generateModbusDefines', () => { '#define MBSERIAL_IFACE Serial', '#define MBSERIAL_BAUD 115200', '#define MBSERIAL_SLAVE 1', + '#define MBSERIAL_SHARES_DEBUG_SERIAL', '#define MBSERIAL', '#define MODBUS_ENABLED', '', @@ -29,6 +30,55 @@ describe('generateModbusDefines', () => { ) }) + it('Phase 2: RTU on the default port takes its baud from the Serial section and shares the debug serial', () => { + const out = generateModbusDefines({ + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial', rtu_slave_id: 1 }, + }) + expect(out).toContain('#define MBSERIAL_IFACE Serial') + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('Phase 2: RTU on a secondary port uses its own baud and does NOT share the debug serial', () => { + const out = generateModbusDefines( + { + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial1', baud_rate: '19200', rtu_slave_id: 1 }, + }, + 'Serial', + ) + expect(out).toContain('#define MBSERIAL_IFACE Serial1') + expect(out).toContain('#define MBSERIAL_BAUD 19200') + expect(out).not.toContain('MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('Phase 2: honors a non-default `defaultSerial` when deciding the shares flag', () => { + const out = generateModbusDefines( + { serial: { baud_rate: '9600' }, modbus_rtu: { enabled: true, serial_port: 'Serial1' } }, + 'Serial1', + ) + // serial_port === defaultSerial → shares, and baud from the Serial section. + expect(out).toContain('#define MBSERIAL_IFACE Serial1') + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('Phase 2: reads TCP network config from the network section', () => { + const out = generateModbusDefines({ + network: { + interface: 'Wi-Fi', + wifi_ssid: 'MyNet', + wifi_password: 'super-secret', + enable_dhcp: true, + }, + modbus_tcp: { enabled: true, unit_id: 1 }, + }) + expect(out).toContain('#define MBTCP_SSID "MyNet"') + expect(out).toContain('#define MBTCP_PWD "super-secret"') + expect(out).toContain('#define MBTCP_WIFI') + }) + it('applies RTU schema defaults when only `enabled: true` is persisted (form-layout writes only touched fields)', () => { // Real-world scenario: user toggles "Enable Modbus RTU" without // editing baud/interface/slave — form-layout writes only the field diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index a5e0b55c2..0510cbb86 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -79,6 +79,10 @@ export interface GenerateDefinesInput { * fixed RTU-over-USART0 block. Web passes `undefined` until * VPP screens land on the web build. */ vppModbusState?: VppModbusScreenState + /** Name of the board's default serial port (from the VPP manifest device's + * `defaultSerial`; `BoardInfo.defaultSerial`). Drives `DEBUG_IFACE` and the + * RTU "shares the debug serial" flag. Absent → `Serial`. */ + defaultSerial?: string } /** @@ -100,7 +104,8 @@ export interface GenerateDefinesInput { * editor-produced and web-produced firmware comes out clean). */ export function generateDefinesContent(input: GenerateDefinesInput): string { - const { boardEntry, devicePinMapping, stProgramFileContent, buildMD5Hash, boardRuntime, vppModbusState } = input + const { boardEntry, devicePinMapping, stProgramFileContent, buildMD5Hash, boardRuntime, vppModbusState, defaultSerial } = + input let DEFINES_CONTENT = '' @@ -145,7 +150,6 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { // Runtime-v4 / runtime-v3 targets route Modbus config through // `conf/modbus_slave.json` in the upload bundle and emit no // macros here. - let modbusEnabled = false if (boardRuntime === 'simulator') { DEFINES_CONTENT += '//Comms Configuration\n' DEFINES_CONTENT += '#define SIMULATOR_MODE\n' @@ -155,28 +159,27 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += '#define MBSERIAL\n' DEFINES_CONTENT += '#define MODBUS_ENABLED\n' DEFINES_CONTENT += `\n\n` - modbusEnabled = true } else if (boardRuntime !== 'openplc-compiler' && vppModbusState) { - const modbusBlock = generateModbusDefines(vppModbusState) + const modbusBlock = generateModbusDefines(vppModbusState, defaultSerial) if (modbusBlock.length > 0) { DEFINES_CONTENT += modbusBlock DEFINES_CONTENT += '\n\n' - modbusEnabled = true } } - // 4b. Debugger — always-on debug over serial for baremetal Arduino targets. - // Emitted only when full Modbus is NOT active: with Modbus off, this is - // the gate that brings up the serial port and the debug function codes - // (0x41-0x48) WITHOUT allocating any operation buffers (coils/holding/ - // etc.), saving SRAM on small boards. When Modbus IS active the debugger - // already rides Modbus's own transport, so emitting DEBUGGER_ENABLED then - // would be redundant (and, in a TCP-only Modbus build, would leave the - // serial port uninitialised). Simulator gets MODBUS_ENABLED above; - // openplc-compiler runtimes don't use this firmware at all. - if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler' && !modbusEnabled) { + // 4b. Debugger — always-on serial debugger for baremetal Arduino targets. + // The default serial port is ALWAYS initialised (DEBUG_IFACE @ DEBUG_BAUD) + // so the debug function codes (0x41-0x48) respond over serial regardless + // of whether Modbus is configured — without allocating operation buffers. + // When Modbus RTU runs on that same default port, generateModbusDefines + // emits MBSERIAL_SHARES_DEBUG_SERIAL so the firmware begins the port once. + // Simulator uses its fixed MODBUS_ENABLED block above; openplc-compiler + // runtimes don't use this firmware at all. + if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler') { DEFINES_CONTENT += '//Debugger\n' DEFINES_CONTENT += '#define DEBUGGER_ENABLED\n' + DEFINES_CONTENT += `#define DEBUG_IFACE ${defaultSerial ?? 'Serial'}\n` + DEFINES_CONTENT += `#define DEBUG_BAUD ${vppModbusState?.serial?.baud_rate ?? '115200'}\n` DEFINES_CONTENT += `\n\n` } diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 693eb0b96..077e9c062 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -28,9 +28,32 @@ * VPP screen field set evolves. */ export interface VppModbusScreenState { + /** Phase 2 Serial section — always-on serial baud (debugger + RTU on the + * default port). */ + serial?: { + baud_rate?: string + } + /** Phase 2 Network section — Ethernet/Wi-Fi config lifted out of modbus_tcp. */ + network?: { + enabled?: boolean + interface?: 'Ethernet' | 'Wi-Fi' + mac_address?: string + wifi_ssid?: string + wifi_password?: string + enable_dhcp?: boolean + ip_address?: string + gateway?: string + subnet?: string + dns?: string + } modbus_rtu?: { enabled?: boolean + /** Phase 2: chosen serial port. Legacy projects use `rtu_interface`. */ + serial_port?: string rtu_interface?: string + /** Phase 2: baud for RTU on a secondary port. On the default port the + * Serial section's baud is used. Legacy projects use `rtu_baud_rate`. */ + baud_rate?: string rtu_baud_rate?: string rtu_slave_id?: number enable_rs485_en_pin?: boolean @@ -38,6 +61,9 @@ export interface VppModbusScreenState { } modbus_tcp?: { enabled?: boolean + unit_id?: number + // Legacy network fields (pre-Phase-2 projects still on the old screen). + // Read as a fallback when the `network` section is absent. tcp_interface?: 'Ethernet' | 'Wi-Fi' tcp_mac_address?: string tcp_wifi_ssid?: string @@ -88,7 +114,6 @@ function formatIpForDefine(raw: string): string { // to compile (ModbusSlave.cpp uses them as object/literal values). // Keep these in sync if the screen schema's defaults change. const RTU_DEFAULTS = { - rtu_interface: 'Serial', rtu_baud_rate: '115200', rtu_slave_id: 1, } as const @@ -110,9 +135,10 @@ const TCP_DEFAULTS = { * The output always ends with a trailing newline so callers can * concatenate without adding their own. */ -export function generateModbusDefines(state: VppModbusScreenState): string { +export function generateModbusDefines(state: VppModbusScreenState, defaultSerial: string = 'Serial'): string { const rtu = state.modbus_rtu ?? {} const tcp = state.modbus_tcp ?? {} + const net = state.network ?? {} const rtuOn = rtu.enabled === true const tcpOn = tcp.enabled === true @@ -122,12 +148,23 @@ export function generateModbusDefines(state: VppModbusScreenState): string { lines.push('//Comms Configuration') if (rtuOn) { - const iface = rtu.rtu_interface ?? RTU_DEFAULTS.rtu_interface - const baud = rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate + // Phase 2: RTU picks a serial port (`serial_port`); legacy projects carry + // `rtu_interface`. On the default port the RTU shares the always-on Serial + // baud; on a secondary port it uses its own (`baud_rate`), with the legacy + // `rtu_baud_rate` as a fallback for pre-migration projects. + const iface = rtu.serial_port ?? rtu.rtu_interface ?? defaultSerial + const onDefaultPort = iface === defaultSerial + const baud = onDefaultPort + ? (state.serial?.baud_rate ?? rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate) + : (rtu.baud_rate ?? rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate) const slave = typeof rtu.rtu_slave_id === 'number' ? rtu.rtu_slave_id : RTU_DEFAULTS.rtu_slave_id lines.push(`#define MBSERIAL_IFACE ${iface}`) lines.push(`#define MBSERIAL_BAUD ${baud}`) lines.push(`#define MBSERIAL_SLAVE ${slave}`) + // The RTU port IS the debugger's default serial → tell the firmware to + // initialise the port once (the always-on debugger already begins it) + // instead of calling begin() twice. + if (onDefaultPort) lines.push('#define MBSERIAL_SHARES_DEBUG_SERIAL') if (rtu.enable_rs485_en_pin === true && rtu.rtu_rs485_en_pin) { lines.push(`#define MBSERIAL_TXPIN ${rtu.rtu_rs485_en_pin}`) } @@ -135,34 +172,34 @@ export function generateModbusDefines(state: VppModbusScreenState): string { } if (tcpOn) { - // MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY / MBTCP_SUBNET - // are referenced unconditionally inside the `#ifdef MBTCP` block in - // `resources/sources/Baremetal/Baremetal.ino` (it builds five byte - // arrays and uses `sizeof(arr) < 4` as a compile-time DHCP-vs-static - // selector that cascades through to `mbconfig_ethernet_iface(mac, - // …, NULL, NULL, …)`). Missing a single macro fails compilation; an - // unset macro is signalled by emitting a single-byte `0` so the - // array has `sizeof == 1`, the `< 4` check fires, and the runtime - // falls back to the DHCP/NULL path. Wi-Fi mode ignores these args - // inside `mbconfig_ethernet_iface` (see `ModbusSlave.cpp:199-225`), - // so the placeholder values are harmless there too. - const macLiteral = tcp.tcp_mac_address ? formatMacForDefine(tcp.tcp_mac_address) : '0' - lines.push(`#define MBTCP_MAC ${macLiteral}`) + // Network config comes from the Phase 2 `network` section, falling back to + // the legacy `modbus_tcp` fields for pre-migration projects. + // + // MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY / MBTCP_SUBNET are + // referenced unconditionally inside the `#ifdef MBTCP` block in + // `Baremetal.ino` (five byte arrays, `sizeof(arr) < 4` as a compile-time + // DHCP-vs-static selector). A missing macro fails compilation; an unset + // value is signalled by a single-byte `0` so the `< 4` check fires and the + // runtime falls back to the DHCP/NULL path. + const mac = net.mac_address ?? tcp.tcp_mac_address + const ifaceSel = net.interface ?? tcp.tcp_interface ?? TCP_DEFAULTS.tcp_interface + const dhcpOn = (net.enable_dhcp ?? tcp.enable_dhcp) === true + const ip = net.ip_address ?? tcp.ip_address + const dns = net.dns ?? tcp.dns + const gateway = net.gateway ?? tcp.gateway + const subnet = net.subnet ?? tcp.subnet + const ssid = net.wifi_ssid ?? tcp.tcp_wifi_ssid + const pwd = net.wifi_password ?? tcp.tcp_wifi_password - const dhcpOn = tcp.enable_dhcp === true - const ipLiteral = !dhcpOn && tcp.ip_address ? formatIpForDefine(tcp.ip_address) : '0' - const dnsLiteral = !dhcpOn && tcp.dns ? formatIpForDefine(tcp.dns) : '0' - const gatewayLiteral = !dhcpOn && tcp.gateway ? formatIpForDefine(tcp.gateway) : '0' - const subnetLiteral = !dhcpOn && tcp.subnet ? formatIpForDefine(tcp.subnet) : '0' - lines.push(`#define MBTCP_IP ${ipLiteral}`) - lines.push(`#define MBTCP_DNS ${dnsLiteral}`) - lines.push(`#define MBTCP_GATEWAY ${gatewayLiteral}`) - lines.push(`#define MBTCP_SUBNET ${subnetLiteral}`) + lines.push(`#define MBTCP_MAC ${mac ? formatMacForDefine(mac) : '0'}`) + lines.push(`#define MBTCP_IP ${!dhcpOn && ip ? formatIpForDefine(ip) : '0'}`) + lines.push(`#define MBTCP_DNS ${!dhcpOn && dns ? formatIpForDefine(dns) : '0'}`) + lines.push(`#define MBTCP_GATEWAY ${!dhcpOn && gateway ? formatIpForDefine(gateway) : '0'}`) + lines.push(`#define MBTCP_SUBNET ${!dhcpOn && subnet ? formatIpForDefine(subnet) : '0'}`) - const iface = tcp.tcp_interface ?? TCP_DEFAULTS.tcp_interface - if (iface === 'Wi-Fi') { - if (tcp.tcp_wifi_ssid) lines.push(`#define MBTCP_SSID "${tcp.tcp_wifi_ssid}"`) - if (tcp.tcp_wifi_password) lines.push(`#define MBTCP_PWD "${tcp.tcp_wifi_password}"`) + if (ifaceSel === 'Wi-Fi') { + if (ssid) lines.push(`#define MBTCP_SSID "${ssid}"`) + if (pwd) lines.push(`#define MBTCP_PWD "${pwd}"`) lines.push('#define MBTCP_WIFI') } else { lines.push('#define MBTCP_ETHERNET') From cfd7e9429207c086af0ce68c6d9c11532f279354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 12:13:43 +0200 Subject: [PATCH 14/79] feat(firmware): keep the debugger serial up on Modbus TCP-only builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: with the always-on debugger now emitted unconditionally (DEBUGGER_ENABLED + DEBUG_IFACE/DEBUG_BAUD), a Modbus-TCP-only build (MODBUS_ENABLED without MBSERIAL) previously left the default serial uninitialised, so the debugger had no port. setup() now brings up DEBUG_IFACE @ DEBUG_BAUD on mb_serialport in that case. Single-serial model note: the debugger and Modbus RTU share one mb_serialport; when MBSERIAL_SHARES_DEBUG_SERIAL is set the RTU port IS the debugger's default serial (single begin). Running the debugger on the default serial while RTU uses a different UART simultaneously needs a second serial handler — documented follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/sources/Baremetal/Baremetal.ino | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index adcbeaf32..005b78773 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -155,6 +155,18 @@ void setup() mbconfig_serial_iface(&MBSERIAL_IFACE, MBSERIAL_BAUD, -1); #endif modbus.slaveid = MBSERIAL_SLAVE; + // NOTE (single-serial model): the debugger and Modbus RTU share one + // mb_serialport. When MBSERIAL_SHARES_DEBUG_SERIAL is defined the RTU + // port IS the debugger's default serial, so this single begin() also + // brings up the debugger. Running the debugger on the default USB + // serial while RTU uses a *different* UART simultaneously would need + // a second serial handler — a documented follow-up. + #elif defined(DEBUGGER_ENABLED) + // Modbus TCP-only build: no MBSERIAL, but the always-on debugger + // still needs the default serial up on mb_serialport to respond. + DEBUG_IFACE.begin(DEBUG_BAUD); + mbconfig_serial_iface(&DEBUG_IFACE, DEBUG_BAUD, -1); + modbus.slaveid = DEBUG_SLAVE; #endif #ifdef MBTCP From 74ab1b2ebccec4eb7263d4782745a4c0bb8cbfa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 12:16:54 +0200 Subject: [PATCH 15/79] feat(debugger): fall back to the serial channel when no channel matches Phase 2: the always-on debugger keeps serial debug compiled into every baremetal firmware even with Modbus disabled, so resolveDebugConnection now falls back to the serial (rtu) channel instead of surfacing "Modbus Required" when no channel's enabledWhen matches. A TCP-only Modbus build leaves the tcp channel eligible, so it never hits the fallback and correctly debugs over TCP. Errors only when there is no serial channel at all. The serial channel's baud is sourced from the Serial section (screens.serial. baud_rate) via the VPP debug spec (NodeMCU pilot). 31 tests pass, 100% stmts/lines/functions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hardware/__tests__/debug-spec.test.ts | 48 +++++++++++++++++-- src/backend/shared/hardware/debug-spec.ts | 29 +++++++---- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/backend/shared/hardware/__tests__/debug-spec.test.ts b/src/backend/shared/hardware/__tests__/debug-spec.test.ts index d34d78455..cfa6efbbf 100644 --- a/src/backend/shared/hardware/__tests__/debug-spec.test.ts +++ b/src/backend/shared/hardware/__tests__/debug-spec.test.ts @@ -65,7 +65,10 @@ describe('resolveDebugConnection', () => { }) describe('channel selection', () => { - it('errors with `noneEnabled` message when no channel matches', () => { + it('falls back to the serial (rtu) channel when no channel matches (always-on debugger)', () => { + // The always-on debugger keeps serial debug compiled into every + // baremetal firmware even with Modbus disabled, so an rtu channel is + // always usable as a fallback instead of surfacing "Modbus Required". const spec: DebugSpec = { channels: [ { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, @@ -74,14 +77,30 @@ describe('resolveDebugConnection', () => { messages: { noneEnabled: { title: 'Modbus Required', body: 'Enable RTU or TCP.' } }, } const result = resolveDebugConnection(spec, makeContext()) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('rtu') + expect(result.channelLabel).toBe('RTU') + } + }) + + it('errors with `noneEnabled` message when nothing matches and there is no serial fallback', () => { + // Only a non-serial channel exists, so there is no always-on serial + // fallback — the board genuinely has no usable debug channel. + const spec: DebugSpec = { + channels: [{ label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }], + messages: { noneEnabled: { title: 'Modbus Required', body: 'Enable RTU or TCP.' } }, + } + const result = resolveDebugConnection(spec, makeContext()) expect(result).toEqual({ kind: 'error', title: 'Modbus Required', body: 'Enable RTU or TCP.' }) }) - it('falls back to generic copy when `noneEnabled` message is absent', () => { - // `messages.noneEnabled` is optional — boards may omit it and - // expect the resolver to provide a sensible default. + it('falls back to generic copy when `noneEnabled` message is absent and no serial fallback', () => { + // `messages.noneEnabled` is optional — boards may omit it and expect the + // resolver to provide a sensible default. Uses a tcp-only spec so the + // serial fallback does not apply. const spec: DebugSpec = { - channels: [{ label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }], + channels: [{ label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }], } const result = resolveDebugConnection(spec, makeContext()) expect(result).toEqual({ @@ -91,6 +110,25 @@ describe('resolveDebugConnection', () => { }) }) + it('does NOT fall back to serial when a non-serial channel is enabled (TCP-only Modbus)', () => { + // TCP-only Modbus build: the tcp channel matches, so the resolver uses + // it and never offers serial (which the firmware does not expose here). + const spec: DebugSpec = { + channels: [ + { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, + { label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }, + ], + } + const result = resolveDebugConnection( + spec, + makeContext({ state: { screens: { modbus_tcp: { enabled: true } } } }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('tcp') + } + }) + it('returns `pick` when multiple channels match', () => { const spec: DebugSpec = { channels: [ diff --git a/src/backend/shared/hardware/debug-spec.ts b/src/backend/shared/hardware/debug-spec.ts index fabd68fab..ba192a973 100644 --- a/src/backend/shared/hardware/debug-spec.ts +++ b/src/backend/shared/hardware/debug-spec.ts @@ -199,14 +199,26 @@ export function resolveDebugConnection( .map((channel, index) => ({ channel, index })) .filter(({ channel }) => evaluateCondition(channel.enabledWhen, context.state)) if (enabled.length === 0) { - const msg = spec.messages?.noneEnabled - return { - kind: 'error', - title: msg?.title ?? 'No Debug Channel', - body: msg?.body ?? 'No debug channel is enabled for this board.', + // Always-on debugger: every baremetal firmware keeps the serial debug + // function codes compiled in even when no Modbus transport is enabled + // (the DEBUGGER_ENABLED gate brings up the serial port without Modbus + // operation buffers). So when no channel's `enabledWhen` matches, fall + // back to the serial (`rtu`) channel instead of erroring — serial debug + // is always available. A TCP-only Modbus build leaves `enabled` non-empty + // (the `tcp` channel matches), so it never reaches this fallback and + // correctly debugs over TCP, matching the firmware which does NOT bring + // up the serial debugger in that configuration. + const rtuFallbackIndex = spec.channels.findIndex((channel) => channel.channel === 'rtu') + if (rtuFallbackIndex < 0) { + const msg = spec.messages?.noneEnabled + return { + kind: 'error', + title: msg?.title ?? 'No Debug Channel', + body: msg?.body ?? 'No debug channel is enabled for this board.', + } } - } - if (enabled.length > 1) { + activeIndex = rtuFallbackIndex + } else if (enabled.length > 1) { const msg = spec.messages?.pickProtocol return { kind: 'pick', @@ -214,8 +226,9 @@ export function resolveDebugConnection( title: msg?.title ?? 'Select Debug Channel', body: msg?.body ?? 'Multiple debug channels are enabled. Which one should the debugger use?', } + } else { + activeIndex = enabled[0].index } - activeIndex = enabled[0].index } const channel = spec.channels[activeIndex] From 4b3c1386f57a766c1263f0d849a1c4ee9e2ae27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 13:19:39 +0200 Subject: [PATCH 16/79] feat(firmware): run Modbus RTU on a secondary serial while the debugger keeps the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 dual-serial: support the debugger on the default (USB) serial AND Modbus RTU on a distinct UART simultaneously. - modbus-defines: emit MBSERIAL_ON_SECONDARY when the RTU serial_port differs from the board's default serial (and MBSERIAL_SHARES_DEBUG_SERIAL when it's the same port). Tested, 100% stmts/lines/functions. - ModbusSlave.cpp: factor the serial servicing into handle_serial_port(port, txpin, slaveid, buf, len, last) with a parametrised mb_rtu_drop_front. Under MBSERIAL_ON_SECONDARY, two contexts each own an RX buffer (debug + rtu) and mb_frame is transient process/TX scratch; otherwise the single-port path is unchanged (buf IS mb_frame, no copy) — zero RAM cost on single-UART boards. - Baremetal.ino: bring up both serials in setup() under the dual-serial macro. - ModbusSlave.h: DEBUG_* defaults now apply whenever DEBUGGER_ENABLED (the dual path needs DEBUG_SLAVE even with MBSERIAL defined). RAM cost (dual builds only): 2*MAX_MB_FRAME + 12 bytes (268 B on 32U4, 524 B on 256-frame boards). Firmware verified via arduino-cli build (multi-UART board). Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/sources/Baremetal/Baremetal.ino | 5 + resources/sources/Baremetal/ModbusSlave.cpp | 117 +++++++++++++----- resources/sources/Baremetal/ModbusSlave.h | 10 +- .../compile/__tests__/modbus-defines.test.ts | 3 + .../shared/compile/steps/modbus-defines.ts | 13 +- 5 files changed, 106 insertions(+), 42 deletions(-) diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 005b78773..ce3de63b7 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -130,6 +130,11 @@ void setup() #ifdef MODBUS_ENABLED #ifdef MBSERIAL + #ifdef MBSERIAL_ON_SECONDARY + // Dual-serial: Modbus RTU runs on a secondary UART (below) while + // the always-on debugger keeps the default serial — bring it up. + DEBUG_IFACE.begin(DEBUG_BAUD); + #endif #ifdef MBSERIAL_TXPIN // Disable TX pin from OpenPLC hardware layer for (int i = 0; i < NUM_DISCRETE_INPUT; i++) diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index 8350f6b1d..a8212ae82 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -428,6 +428,23 @@ void handle_tcp() static uint16_t mb_rx_len = 0; static uint32_t mb_rx_last_ms = 0; +#ifdef MBSERIAL_ON_SECONDARY +// Dual-serial: the debugger keeps the default serial while Modbus RTU runs on a +// distinct UART. Each port needs its OWN RX assembly buffer — a partial frame on +// one port must survive while the other is serviced. `mb_frame` becomes a +// transient process/TX buffer, borrowed for one complete transaction at a time +// (safe: Modbus RTU is half-duplex turn-taking and the ports are polled +// sequentially). These extra buffers are compiled ONLY for boards that use a +// secondary Modbus serial (multi-UART, RAM-rich), so single-UART boards keep the +// original single-buffer footprint. +static uint8_t mb_rx_dbg[MAX_MB_FRAME]; +static uint16_t mb_rx_dbg_len = 0; +static uint32_t mb_rx_dbg_last_ms = 0; +static uint8_t mb_rx_rtu[MAX_MB_FRAME]; +static uint16_t mb_rx_rtu_len = 0; +static uint32_t mb_rx_rtu_last_ms = 0; +#endif + // Total on-wire length (slave id + PDU + 2 CRC bytes) of the request whose // first `n` bytes are in `f`. Returns >0 for a known length, 0 when more header // bytes are needed to size it, and -1 for a function code we do not serve (so @@ -475,25 +492,32 @@ static int32_t mb_rtu_frame_len(const uint8_t *f, uint16_t n) // so a genuine frame head sitting further into the buffer always survives and // is eventually found (guarantees resync convergence; no "discard every frame" // loop). Slides only run on the error path, so the O(n) cost is irrelevant. -static void mb_rtu_drop_front(uint16_t k) +static void mb_rtu_drop_front(uint8_t *buf, uint16_t *plen, uint16_t k) { - if (k >= mb_rx_len) { mb_rx_len = 0; return; } - for (uint16_t i = k; i < mb_rx_len; i++) - mb_frame[i - k] = mb_frame[i]; - mb_rx_len = (uint16_t)(mb_rx_len - k); + if (k >= *plen) { *plen = 0; return; } + for (uint16_t i = k; i < *plen; i++) + buf[i - k] = buf[i]; + *plen = (uint16_t)(*plen - k); } -void handle_serial() +// Service ONE serial port. `buf`/`plen`/`plast` are the port's own RX-assembly +// state; `slaveid` is its framing id; `txpin` its RS485 driver-enable pin (-1 +// when none). A complete frame is copied into the shared `mb_frame`, processed, +// and the response written back to `port`. In the single-serial build `buf` IS +// `mb_frame` (in-place, no copy); in the dual-serial build each port owns a +// distinct buffer and `mb_frame` is the transient process/TX scratch. +static void handle_serial_port(Stream *port, int8_t txpin, uint8_t slaveid, + uint8_t *buf, uint16_t *plen, uint32_t *plast) { uint16_t packet_crc; // 1) Drain the RX buffer without blocking. One frame's bytes may arrive // across several calls; the scan cycle is never stalled waiting on them. - while ((*mb_serialport).available() > 0) + while (port->available() > 0) { - if (mb_rx_len >= MAX_MB_FRAME) break; // full — let the parser drain it - mb_frame[mb_rx_len++] = (uint8_t)(*mb_serialport).read(); - mb_rx_last_ms = millis(); + if (*plen >= MAX_MB_FRAME) break; // full — let the parser drain it + buf[(*plen)++] = (uint8_t)port->read(); + *plast = millis(); } // 2) Extract every complete frame in the buffer. Each iteration either @@ -501,36 +525,43 @@ void handle_serial() // loop always terminates. for (;;) { - if (mb_rx_len == 0) + if (*plen == 0) return; - // Header byte-alignment: the first byte must be OUR slave id. This is - // the cheap framing check, and it is the ONLY validation applied to - // debugger frames (CRC is deliberately skipped on debug FCs for + // Header byte-alignment: the first byte must be THIS port's slave id. + // This is the cheap framing check, and it is the ONLY validation applied + // to debugger frames (CRC is deliberately skipped on debug FCs for // performance — those function codes are private and well-formed). - if (mb_frame[0] != modbus.slaveid) + if (buf[0] != slaveid) { - mb_rtu_drop_front(1); // foreign/garbage head — slide + mb_rtu_drop_front(buf, plen, 1); // foreign/garbage head — slide continue; } - int32_t expected = mb_rtu_frame_len(mb_frame, mb_rx_len); + int32_t expected = mb_rtu_frame_len(buf, *plen); if (expected < 0 || expected > MAX_MB_FRAME) { - mb_rtu_drop_front(1); // illegal FC / impossible length + mb_rtu_drop_front(buf, plen, 1); // illegal FC / impossible length continue; } - if (expected == 0 || mb_rx_len < (uint16_t)expected) + if (expected == 0 || *plen < (uint16_t)expected) { // Header incomplete, or the frame's tail has not arrived yet. Wait // for it; abandon the partial only if its remainder never comes. - if ((uint32_t)(millis() - mb_rx_last_ms) > MB_RTU_FRAME_GAP_MS) - mb_rx_len = 0; + if ((uint32_t)(millis() - *plast) > MB_RTU_FRAME_GAP_MS) + *plen = 0; return; } - // 3) A full candidate frame occupies mb_frame[0 .. expected). + // 3) A full candidate frame occupies buf[0 .. expected). Move it into the + // shared process buffer (a no-op self-copy on the single-serial path, + // where buf already IS mb_frame). + if (buf != mb_frame) + { + for (int32_t i = 0; i < expected; i++) mb_frame[i] = buf[i]; + } + // Standard FCs are validated by CRC (the arbiter that makes resync // trustworthy); a mismatch means corruption or misalignment, so we // slide one byte and retry instead of discarding the whole buffer. @@ -540,7 +571,7 @@ void handle_serial() packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); if (packet_crc != calcCrc()) { - mb_rtu_drop_front(1); + mb_rtu_drop_front(buf, plen, 1); continue; } } @@ -558,34 +589,34 @@ void handle_serial() mb_frame[mb_frame_len - 2] = (uint8_t)(packet_crc >> 8); mb_frame[mb_frame_len - 1] = (uint8_t)(packet_crc & 0x00FF); - if (mb_txpin >= 0) + if (txpin >= 0) { - digitalWrite(mb_txpin, HIGH); + digitalWrite(txpin, HIGH); delayMicroseconds(mb_t35); } #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) // RS485 serial port + if (port == &Serial3) // RS485 serial port Controllino_RS485TxEnable(); // Enable RS485 chip to transmit #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { + if (port == &Serial2) { digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, HIGH); digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); } #endif - (*mb_serialport).write(mb_frame, mb_frame_len); - (*mb_serialport).flush(); + port->write(mb_frame, mb_frame_len); + port->flush(); delayMicroseconds(mb_t35); - if (mb_txpin >= 0) - digitalWrite(mb_txpin, LOW); + if (txpin >= 0) + digitalWrite(txpin, LOW); #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) // RS485 serial port + if (port == &Serial3) // RS485 serial port Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { + if (port == &Serial2) { digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, LOW); } @@ -597,10 +628,28 @@ void handle_serial() // can already be buffered. Reset for the next request. A // non-conformant pipelining master simply retransmits after its // timeout, and the gap/realignment logic above recovers cleanly. - mb_rx_len = 0; + *plen = 0; return; } } + +// Dispatch to one or two serial ports. Single-serial: the debugger and Modbus +// RTU (if any) share one port, assembled in-place in mb_frame. Dual-serial +// (MBSERIAL_ON_SECONDARY): the debugger keeps the default serial while Modbus +// RTU runs on a distinct UART — each with its own RX buffer. +void handle_serial() +{ +#ifdef MBSERIAL_ON_SECONDARY + handle_serial_port(&DEBUG_IFACE, -1, DEBUG_SLAVE, mb_rx_dbg, &mb_rx_dbg_len, &mb_rx_dbg_last_ms); + #ifdef MBSERIAL_TXPIN + handle_serial_port(&MBSERIAL_IFACE, MBSERIAL_TXPIN, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #else + handle_serial_port(&MBSERIAL_IFACE, -1, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #endif +#else + handle_serial_port(mb_serialport, mb_txpin, modbus.slaveid, mb_frame, &mb_rx_len, &mb_rx_last_ms); +#endif +} #endif diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 5591eb387..506d9c0f7 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -19,10 +19,12 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #define MB_SERIAL_ACTIVE #endif -// Default serial config for the always-on debugger when full Modbus RTU -// (MBSERIAL_*) is NOT configured. Override any of these in defines.h to change -// the port/baud/slave the debugger listens on. -#if defined(DEBUGGER_ENABLED) && !defined(MBSERIAL) +// Default serial config for the always-on debugger. `defines.h` normally emits +// DEBUG_IFACE / DEBUG_BAUD explicitly (from the Serial screen); these `#ifndef` +// defaults cover anything it left unset. Defined whenever the debugger is on — +// including alongside full Modbus RTU, since the dual-serial path (RTU on a +// secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. +#ifdef DEBUGGER_ENABLED #ifndef DEBUG_IFACE #define DEBUG_IFACE Serial #endif diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index 72c8155f6..94758541b 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -38,6 +38,7 @@ describe('generateModbusDefines', () => { expect(out).toContain('#define MBSERIAL_IFACE Serial') expect(out).toContain('#define MBSERIAL_BAUD 9600') expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + expect(out).not.toContain('MBSERIAL_ON_SECONDARY') }) it('Phase 2: RTU on a secondary port uses its own baud and does NOT share the debug serial', () => { @@ -51,6 +52,8 @@ describe('generateModbusDefines', () => { expect(out).toContain('#define MBSERIAL_IFACE Serial1') expect(out).toContain('#define MBSERIAL_BAUD 19200') expect(out).not.toContain('MBSERIAL_SHARES_DEBUG_SERIAL') + // Distinct UART from the debugger's default → firmware services two serials. + expect(out).toContain('#define MBSERIAL_ON_SECONDARY') }) it('Phase 2: honors a non-default `defaultSerial` when deciding the shares flag', () => { diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 077e9c062..260d7c00e 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -161,10 +161,15 @@ export function generateModbusDefines(state: VppModbusScreenState, defaultSerial lines.push(`#define MBSERIAL_IFACE ${iface}`) lines.push(`#define MBSERIAL_BAUD ${baud}`) lines.push(`#define MBSERIAL_SLAVE ${slave}`) - // The RTU port IS the debugger's default serial → tell the firmware to - // initialise the port once (the always-on debugger already begins it) - // instead of calling begin() twice. - if (onDefaultPort) lines.push('#define MBSERIAL_SHARES_DEBUG_SERIAL') + // On the default port the RTU IS the debugger's serial → tell the firmware + // to begin the port once (the always-on debugger already begins it). On a + // secondary port the RTU runs on a DISTINCT UART while the debugger keeps + // the default serial, so the firmware services two serial ports. + if (onDefaultPort) { + lines.push('#define MBSERIAL_SHARES_DEBUG_SERIAL') + } else { + lines.push('#define MBSERIAL_ON_SECONDARY') + } if (rtu.enable_rs485_en_pin === true && rtu.rtu_rs485_en_pin) { lines.push(`#define MBSERIAL_TXPIN ${rtu.rtu_rs485_en_pin}`) } From 86940ef7c80c13ef31d1c34da243ea113d418b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 10 Jul 2026 11:55:08 +0200 Subject: [PATCH 17/79] refactor(firmware): split ModbusSlave into per-entity modules Break the monolithic ModbusSlave.{cpp,h} into 10 cohesive modbus_* translation units, each owning one concern and its own build gate. Behavior-preserving; validated by arduino-cli builds (RTU single-serial, debug-only/TCP, dual-serial). - modbus_config.h build gates (defines.h + MB_SERIAL_ACTIVE/DEBUG_* derived) - modbus_types.h enums, MBinfo, frame-size/status constants - modbus_frame.* shared seam (mb_frame/mb_frame_len/modbus, exceptionResponse) - modbus_crc.* CRC-16 + tables (defined once; fixes latent per-TU flash dup) - modbus_registers.* register store + operation FCs (#ifdef MODBUS_ENABLED) - modbus_debug.* debugger FCs 0x41-0x48 (home for future licensing FCs) - modbus_pdu.* process_mbpacket + mb_pdu_request_len/mb_pdu_skips_crc - modbus_serial.* RTU/debugger serial transport (single + dual-serial) - modbus_tcp.* Modbus TCP transport (Ethernet/WiFi/ETH) - ModbusSlave.* umbrella header + mbtask() facade (Baremetal.ino unchanged) Key decoupling: the serial transport no longer knows the function-code set. It asks modbus_pdu for per-FC frame shape (mb_pdu_request_len) and CRC policy (mb_pdu_skips_crc), so adding a function code touches only modbus_pdu + its handler, never the transports. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018esifhUpuyPmJB29BneUqr --- resources/sources/Baremetal/ModbusSlave.cpp | 1604 +---------------- resources/sources/Baremetal/ModbusSlave.h | 273 +-- resources/sources/Baremetal/modbus_config.h | 46 + resources/sources/Baremetal/modbus_crc.cpp | 72 + resources/sources/Baremetal/modbus_crc.h | 18 + resources/sources/Baremetal/modbus_debug.cpp | 397 ++++ resources/sources/Baremetal/modbus_debug.h | 29 + resources/sources/Baremetal/modbus_frame.cpp | 21 + resources/sources/Baremetal/modbus_frame.h | 23 + resources/sources/Baremetal/modbus_pdu.cpp | 182 ++ resources/sources/Baremetal/modbus_pdu.h | 34 + .../sources/Baremetal/modbus_registers.cpp | 525 ++++++ .../sources/Baremetal/modbus_registers.h | 31 + resources/sources/Baremetal/modbus_serial.cpp | 269 +++ resources/sources/Baremetal/modbus_serial.h | 32 + resources/sources/Baremetal/modbus_tcp.cpp | 250 +++ resources/sources/Baremetal/modbus_tcp.h | 77 + resources/sources/Baremetal/modbus_types.h | 90 + 18 files changed, 2143 insertions(+), 1830 deletions(-) create mode 100644 resources/sources/Baremetal/modbus_config.h create mode 100644 resources/sources/Baremetal/modbus_crc.cpp create mode 100644 resources/sources/Baremetal/modbus_crc.h create mode 100644 resources/sources/Baremetal/modbus_debug.cpp create mode 100644 resources/sources/Baremetal/modbus_debug.h create mode 100644 resources/sources/Baremetal/modbus_frame.cpp create mode 100644 resources/sources/Baremetal/modbus_frame.h create mode 100644 resources/sources/Baremetal/modbus_pdu.cpp create mode 100644 resources/sources/Baremetal/modbus_pdu.h create mode 100644 resources/sources/Baremetal/modbus_registers.cpp create mode 100644 resources/sources/Baremetal/modbus_registers.h create mode 100644 resources/sources/Baremetal/modbus_serial.cpp create mode 100644 resources/sources/Baremetal/modbus_serial.h create mode 100644 resources/sources/Baremetal/modbus_tcp.cpp create mode 100644 resources/sources/Baremetal/modbus_tcp.h create mode 100644 resources/sources/Baremetal/modbus_types.h diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index a8212ae82..807399e50 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -4,247 +4,16 @@ Copyright (C) 2022 OpenPLC - Thiago Alves */ #include "ModbusSlave.h" -// Debug surface comes via the extern "C" shims in arduino_runtime_glue.h -// (openplc_debug_*) so this TU stays free of strucpp template-heavy headers -// and compiles cleanly in arduino-cli's path with the core's default C++ -// standard (gnu++14 on mbed and others). The shims forward to -// strucpp::debug::handle_* inside arduino_runtime_glue.cpp, which is part -// of the precompiled OpenPLCUserLib archive built with -std=gnu++17. -#include "arduino_runtime_glue.h" +// The debugger handlers (and their arduino_runtime_glue.h / ArduinoUniqueID +// dependencies) moved to modbus_debug.cpp. -// ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. -// It supports AVR/megaAVR/SAM/SAMD/STM32/ESP/RP2040/Teensy. On a core without -// support (or when a board intentionally opts out via OPENPLC_NO_UNIQUE_ID), -// the board-id handler returns id_len = 0 instead of failing to compile. -#ifndef OPENPLC_NO_UNIQUE_ID - #include - #define OPENPLC_HAS_UNIQUE_ID -#endif +// Global Modbus vars — modbus / mb_frame / mb_frame_len moved to modbus_frame.cpp; +// the serial port/timing globals to modbus_serial.cpp; the TCP server state +// (mb_server / mb_serverClients / mb_mbap) to modbus_tcp.cpp. +// init_mbregs / get_discrete / write_discrete moved to modbus_registers.cpp. +// mbconfig_serial_iface() and the serial transport moved to modbus_serial.cpp. +// mbconfig_ethernet_iface() and handle_tcp() moved to modbus_tcp.cpp. -//Global Modbus vars -struct MBinfo modbus; -uint8_t mb_frame[MAX_MB_FRAME]; -uint16_t mb_frame_len; -Stream* mb_serialport; -int8_t mb_txpin; -uint16_t mb_t15; // inter character time out -uint16_t mb_t35; // frame delay - -#ifdef MBTCP_ETHERNET -#ifdef BOARD_ESP32 - WiFiServer mb_server(502); - WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#else - EthernetServer mb_server(502); -#endif - uint8_t mb_mbap[MBAP_SIZE]; -#ifdef BOARD_PORTENTA - EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -#ifdef MBTCP_WIFI - WiFiServer mb_server(502); - uint8_t mb_mbap[MBAP_SIZE]; -#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) - WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus) -{ - //Save sizes - modbus.holding_size = size_holding; - modbus.dint_memory_size = size_dint_memory; - modbus.lint_memory_size = size_lint_memory; - modbus.coils_size = size_coils; - modbus.input_regs_size = size_inputregs; - modbus.input_status_size = size_inputstatus; - - //round discrete regs sizes - if (size_coils % 8 > 0) - size_coils = (size_coils / 8) + 1; - else - size_coils = size_coils / 8; - if (size_inputstatus % 8 > 0) - size_inputstatus = (size_inputstatus / 8) + 1; - else - size_inputstatus = (size_inputstatus / 8); - - modbus.coils = (uint8_t *)malloc(size_coils * sizeof(uint8_t)); - if (modbus.coils == NULL) return false; - memset(modbus.coils, 0, size_coils * sizeof(uint8_t)); - - modbus.holding = (uint16_t *)malloc(size_holding * sizeof(uint16_t)); - if (modbus.holding == NULL) return false; - memset(modbus.holding, 0, size_holding * sizeof(uint16_t)); - - if (size_dint_memory > 0) - { - modbus.dint_memory = (uint32_t *)malloc(size_dint_memory * sizeof(uint32_t)); - if (modbus.dint_memory == NULL) return false; - memset(modbus.dint_memory, 0, size_dint_memory * sizeof(uint32_t)); - } - - if (size_lint_memory > 0) - { - modbus.lint_memory = (uint64_t *)malloc(size_lint_memory * sizeof(uint64_t)); - if (modbus.lint_memory == NULL) return false; - memset(modbus.lint_memory, 0, size_lint_memory * sizeof(uint64_t)); - } - - modbus.input_status = (uint8_t *)malloc(size_inputstatus * sizeof(uint8_t)); - if (modbus.input_status == NULL) return false; - memset(modbus.input_status, 0, size_inputstatus * sizeof(uint8_t)); - - modbus.input_regs = (uint16_t *)malloc(size_inputregs * sizeof(uint16_t)); - if (modbus.input_regs == NULL) return false; - memset(modbus.input_regs, 0, size_inputregs * sizeof(uint16_t)); - - return true; -} - -bool get_discrete(uint16_t addr, bool regtype) -{ - uint8_t byte_addr = addr / 8; - uint8_t bit_addr = addr % 8; - if (regtype == COILS) - return bitRead(modbus.coils[byte_addr], bit_addr); - else - return bitRead(modbus.input_status[byte_addr], bit_addr); -} - -void write_discrete(uint16_t addr, bool regtype, bool value) -{ - uint8_t byte_addr = addr / 8; - uint8_t bit_addr = addr % 8; - if (regtype == COILS) - bitWrite(modbus.coils[byte_addr], bit_addr, value); - else - bitWrite(modbus.input_status[byte_addr], bit_addr, value); -} - -void mbconfig_serial_iface(Stream* port, long baud, int txPin) -{ - mb_serialport = port; - mb_txpin = txPin; - //(*port).begin(baud); //Initialization already happened on main .ino file - - //RS-485 control - if (txPin >= 0) - { - pinMode(txPin, OUTPUT); - digitalWrite(txPin, LOW); - } - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) - Controllino_RS485Init(); - #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { - pinMode(CUSTOM_RS485_DEFAULT_DE_PIN, OUTPUT); - pinMode(CUSTOM_RS485_DEFAULT_RE_PIN, OUTPUT); - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); - } - #endif - - // Modbus states that a baud rate higher than 19200 must use a fixed 750 us - // for inter character time out. For baud rates below 19200 the timing - // is more critical and has to be calculated. - // E.g. 9600 baud in a 11 bit packet is 9600/11 = 872 characters per second - // In milliseconds this will be 872 characters per 1000ms. So for 1 character - // 1000ms/872 characters is 1.14583ms per character. Finally modbus states - // an inter-character must be 1.5T or 1.5 times longer than a character. Thus - // 1.5T = 1.14583ms * 1.5 = 1.71875ms. - // Thus the formula is T1.5(us) = (1000ms * 1000(us) * 1.5 * 11bits)/baud - // 1000ms * 1000(us) * 1.5 * 11bits = 16500000 can be calculated as a constant - - if (baud > 19200) - mb_t15 = 750; - else - mb_t15 = 16500000/baud; // 1T * 1.5 = T1.5 - - /* The modbus definition of a frame delay is a waiting period of 3.5 character times - between packets.*/ - - mb_t35 = mb_t15 * 3.5; -} - - -#ifdef MBTCP -void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet) -{ - #ifdef MBTCP_ETHERNET - #ifdef BOARD_ESP32 - - ETH.begin(); - - if (ip != NULL && subnet != NULL && gateway != NULL) - (ETH.config(ip, gateway, subnet, dns)); - - #else - if (ip == NULL) - Ethernet.begin(mac); - else if (dns == NULL) - Ethernet.begin(mac, IPAddress(ip)); - else if (gateway == NULL) - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns)); - else if (subnet == NULL) - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway)); - else - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); - #endif - -// int num_tries = 0; -// while (!ETH.linkUp()) -// { -// delay(500); -// num_tries++; -// if (num_tries == 20) break; -// } - - #endif - #ifdef MBTCP_WIFI - #if defined(BOARD_ESP8266) || defined(BOARD_ESP32) - if (ip != NULL && gateway != NULL && subnet != NULL && dns != NULL) - { - uint8_t secondaryDNS[] = {8, 8, 8, 8}; - WiFi.config(IPAddress(ip), IPAddress(gateway), IPAddress(subnet), IPAddress(dns), IPAddress(secondaryDNS)); - } - mb_server.setNoDelay(true); - #elif defined(BOARD_PORTENTA) - if (ip != NULL && subnet != NULL && gateway != NULL) - { - WiFi.config(IPAddress(ip), IPAddress(subnet), IPAddress(gateway)); - } - #else - if (ip != NULL) - { - if (dns == NULL) - WiFi.config(IPAddress(ip)); - else if (gateway == NULL) - WiFi.config(IPAddress(ip), IPAddress(dns)); - else if (subnet == NULL) - WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway)); - else - WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); - } - #endif - WiFi.begin(MBTCP_SSID, MBTCP_PWD); - int num_tries = 0; - while (WiFi.status() != WL_CONNECTED) - { - delay(500); - num_tries++; - if (num_tries == 10) break; - } - #endif - - mb_server.begin(); - -} -#endif void mbtask() { @@ -256,1362 +25,19 @@ void mbtask() #endif } -#ifdef MBTCP -void handle_tcp() -{ - #ifdef MBTCP_ETHERNET - #ifdef BOARD_ESP32 - WiFiClient client = mb_server.available(); - #else - EthernetClient client = mb_server.available(); - #endif - #endif - - #if defined(MBTCP_WIFI) && !defined(BOARD_ESP8266) && !defined(BOARD_ESP32) - WiFiClient client = mb_server.available(); - #endif - - //ESP and Portenta boards have a slightly different implementation of the WiFi/Ethernet API - therefore their specific - //code lies below - #if (defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA)) || defined(BOARD_PICOW) && (defined(MBTCP_WIFI) || defined(MBTCP_ETHERNET)) - - - #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || (defined(BOARD_ESP32) && defined(MBTCP_ETHERNET)) - if (client) - #else - if (mb_server.hasClient()) - #endif - { - for (int i = 0; i < MAX_SRV_CLIENTS; i++) - { - if (!mb_serverClients[i]) //equivalent to !serverClients[i].connected() - { - #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || defined(BOARD_ESP32) && defined(MBTCP_ETHERNET) - mb_serverClients[i] = client; - #else - mb_serverClients[i] = mb_server.available(); - #endif - break; - } - } - } - - //search all clients for data - for (int i = 0; i < MAX_SRV_CLIENTS; i++) - { - int j = 0; - - - if (mb_serverClients[i].connected() && mb_serverClients[i].available()) - - { - //Read packet - - - while (mb_serverClients[i].available()) - { - mb_mbap[j] = mb_serverClients[i].read(); - j++; - if (j==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) - } - - mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; - - if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big - - j = 0; - while (mb_serverClients[i].available()) - { - mb_frame[j] = mb_serverClients[i].read(); - j++; - if (j==mb_frame_len) break; - } - - //Safety check - discard packages that lie about their size - if (j != mb_frame_len) return; - - //Process packet and write back - process_mbpacket(); - //Calculate packet length for MBAP header (mb_frame_len + 1) - mb_mbap[4] = (mb_frame_len) >> 8; - mb_mbap[5] = (mb_frame_len) & 0x00FF; - - uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; - - //MBAP - for (j = 0 ; j < MBAP_SIZE ; j++) - sendbuffer[j] = mb_mbap[j]; - - //PDU Frame - for (j = 0 ; j < mb_frame_len ; j++) - sendbuffer[j+MBAP_SIZE] = mb_frame[j]; - - //Write back - mb_serverClients[i].write(sendbuffer, mb_frame_len + MBAP_SIZE); - } - } - - //If this is not an ESP board or Portenta board, then here is the default code - #else - if (client) - { - if (client.connected()) - { - int i = 0; - while (client.available()) - { - mb_mbap[i] = client.read(); - i++; - if (i==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) - } - - mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; - - if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big - - i = 0; - while (client.available()) - { - mb_frame[i] = client.read(); - i++; - if (i==mb_frame_len || i==MAX_MB_FRAME) break; - } - - //Safety check - discard packages that lie about their size - if (i != mb_frame_len) return; - - //Process packet and write back - process_mbpacket(); - //Calculate packet length for MBAP header (mb_frame_len + 1) - mb_mbap[4] = (mb_frame_len) >> 8; - mb_mbap[5] = (mb_frame_len) & 0x00FF; - - uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; - - //MBAP - for (i = 0 ; i < MBAP_SIZE ; i++) - sendbuffer[i] = mb_mbap[i]; - - //PDU Frame - for (i = 0 ; i < mb_frame_len ; i++) - sendbuffer[i+MBAP_SIZE] = mb_frame[i]; - - //Write back - client.write(sendbuffer, mb_frame_len + MBAP_SIZE); - } - } - #endif -} -#endif - -#ifdef MB_SERIAL_ACTIVE -// Inter-frame idle, in milliseconds, used ONLY to abandon a frame whose -// remainder never arrives. Modbus RTU was defined for RS485, where bytes of a -// frame are ~one character time apart (T1.5/T3.5, tens of microseconds at -// 115200) and the byte cadence delimits frames. That assumption is INVALID on -// USB-CDC (and any store-and-forward link): a single request is split into -// 64-byte USB packets separated by USB-frame-scale gaps far longer than T1.5, -// so cadence framing tears requests apart (the bug that made the P1AM-100 / -// SAMD21 debugger crawl). We therefore frame by the request's DECLARED length -// (derived from the function code) and fall back to this idle only to drop a -// truncated partial. It must exceed any intra-frame USB gap yet stay well below -// a master's request timeout. -#define MB_RTU_FRAME_GAP_MS 8 - -// Persistent RX-assembly state. handle_serial() is called every scan cycle and -// never blocks; a request whose bytes straddle several calls is carried across -// them in mb_frame[0..mb_rx_len). (This shares mb_frame with handle_tcp, which -// is safe because an OpenPLC board is configured for a single Modbus transport; -// the two are not driven mid-frame at the same time.) -static uint16_t mb_rx_len = 0; -static uint32_t mb_rx_last_ms = 0; - -#ifdef MBSERIAL_ON_SECONDARY -// Dual-serial: the debugger keeps the default serial while Modbus RTU runs on a -// distinct UART. Each port needs its OWN RX assembly buffer — a partial frame on -// one port must survive while the other is serviced. `mb_frame` becomes a -// transient process/TX buffer, borrowed for one complete transaction at a time -// (safe: Modbus RTU is half-duplex turn-taking and the ports are polled -// sequentially). These extra buffers are compiled ONLY for boards that use a -// secondary Modbus serial (multi-UART, RAM-rich), so single-UART boards keep the -// original single-buffer footprint. -static uint8_t mb_rx_dbg[MAX_MB_FRAME]; -static uint16_t mb_rx_dbg_len = 0; -static uint32_t mb_rx_dbg_last_ms = 0; -static uint8_t mb_rx_rtu[MAX_MB_FRAME]; -static uint16_t mb_rx_rtu_len = 0; -static uint32_t mb_rx_rtu_last_ms = 0; -#endif - -// Total on-wire length (slave id + PDU + 2 CRC bytes) of the request whose -// first `n` bytes are in `f`. Returns >0 for a known length, 0 when more header -// bytes are needed to size it, and -1 for a function code we do not serve (so -// the byte cannot be a frame head). Length is implicit in Modbus RTU — derived -// per function code, exactly as `process_mbpacket()` later parses the fields. -static int32_t mb_rtu_frame_len(const uint8_t *f, uint16_t n) -{ - if (n < 2) return 0; // need at least id + FC - switch (f[1]) - { - case MB_FC_READ_COILS: - case MB_FC_READ_INPUT_STAT: - case MB_FC_READ_REGS: - case MB_FC_READ_INPUT_REGS: - case MB_FC_WRITE_COIL: - case MB_FC_WRITE_REG: - return 8; // [id][fc][a:2][b:2][crc:2] - case MB_FC_WRITE_COILS: - case MB_FC_WRITE_REGS: - if (n < 7) return 0; // byte count lives at f[6] - return 9 + (int32_t)f[6]; // + [bc:1][data:bc][crc:2] - case MB_FC_DEBUG_INFO: - return 4; // [id][fc][crc:2] - case MB_FC_DEBUG_GET: - return 9; // [id][fc][arr:1][s:2][e:2][crc:2] - case MB_FC_DEBUG_GET_LIST: - if (n < 4) return 0; // count lives at f[2..3] - return 6 + 3 * (int32_t)(((uint16_t)f[2] << 8) | f[3]); - case MB_FC_DEBUG_SET: - if (n < 8) return 0; // value len lives at f[6..7] - return 10 + (int32_t)(((uint16_t)f[6] << 8) | f[7]); - case MB_FC_DEBUG_GET_MD5: - return 8; // [id][fc][endian:2][00:2][crc:2] - case MB_FC_DEBUG_GET_STATUS: - case MB_FC_DEBUG_GET_VERSION: - case MB_FC_DEBUG_GET_BOARD_ID: - return 4; // [id][fc][crc:2] - default: - return -1; // not one of our function codes - } -} - -// Drop the first `k` bytes of the assembly buffer, keeping the remainder. Used -// for one-byte realignment on a bad/foreign frame head — NEVER a blind flush — -// so a genuine frame head sitting further into the buffer always survives and -// is eventually found (guarantees resync convergence; no "discard every frame" -// loop). Slides only run on the error path, so the O(n) cost is irrelevant. -static void mb_rtu_drop_front(uint8_t *buf, uint16_t *plen, uint16_t k) -{ - if (k >= *plen) { *plen = 0; return; } - for (uint16_t i = k; i < *plen; i++) - buf[i - k] = buf[i]; - *plen = (uint16_t)(*plen - k); -} - -// Service ONE serial port. `buf`/`plen`/`plast` are the port's own RX-assembly -// state; `slaveid` is its framing id; `txpin` its RS485 driver-enable pin (-1 -// when none). A complete frame is copied into the shared `mb_frame`, processed, -// and the response written back to `port`. In the single-serial build `buf` IS -// `mb_frame` (in-place, no copy); in the dual-serial build each port owns a -// distinct buffer and `mb_frame` is the transient process/TX scratch. -static void handle_serial_port(Stream *port, int8_t txpin, uint8_t slaveid, - uint8_t *buf, uint16_t *plen, uint32_t *plast) -{ - uint16_t packet_crc; - - // 1) Drain the RX buffer without blocking. One frame's bytes may arrive - // across several calls; the scan cycle is never stalled waiting on them. - while (port->available() > 0) - { - if (*plen >= MAX_MB_FRAME) break; // full — let the parser drain it - buf[(*plen)++] = (uint8_t)port->read(); - *plast = millis(); - } - - // 2) Extract every complete frame in the buffer. Each iteration either - // consumes/realigns by >=1 byte or returns to await more data, so the - // loop always terminates. - for (;;) - { - if (*plen == 0) - return; - - // Header byte-alignment: the first byte must be THIS port's slave id. - // This is the cheap framing check, and it is the ONLY validation applied - // to debugger frames (CRC is deliberately skipped on debug FCs for - // performance — those function codes are private and well-formed). - if (buf[0] != slaveid) - { - mb_rtu_drop_front(buf, plen, 1); // foreign/garbage head — slide - continue; - } - - int32_t expected = mb_rtu_frame_len(buf, *plen); - - if (expected < 0 || expected > MAX_MB_FRAME) - { - mb_rtu_drop_front(buf, plen, 1); // illegal FC / impossible length - continue; - } - if (expected == 0 || *plen < (uint16_t)expected) - { - // Header incomplete, or the frame's tail has not arrived yet. Wait - // for it; abandon the partial only if its remainder never comes. - if ((uint32_t)(millis() - *plast) > MB_RTU_FRAME_GAP_MS) - *plen = 0; - return; - } - - // 3) A full candidate frame occupies buf[0 .. expected). Move it into the - // shared process buffer (a no-op self-copy on the single-serial path, - // where buf already IS mb_frame). - if (buf != mb_frame) - { - for (int32_t i = 0; i < expected; i++) mb_frame[i] = buf[i]; - } - - // Standard FCs are validated by CRC (the arbiter that makes resync - // trustworthy); a mismatch means corruption or misalignment, so we - // slide one byte and retry instead of discarding the whole buffer. - if (mb_frame[1] != MB_FC_DEBUG_INFO && mb_frame[1] != MB_FC_DEBUG_SET && mb_frame[1] != MB_FC_DEBUG_GET && mb_frame[1] != MB_FC_DEBUG_GET_LIST && mb_frame[1] != MB_FC_DEBUG_GET_MD5 && mb_frame[1] != MB_FC_DEBUG_GET_STATUS && mb_frame[1] != MB_FC_DEBUG_GET_VERSION && mb_frame[1] != MB_FC_DEBUG_GET_BOARD_ID) - { - mb_frame_len = (uint16_t)expected; - packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); - if (packet_crc != calcCrc()) - { - mb_rtu_drop_front(buf, plen, 1); - continue; - } - } - - // 4) Accepted. Hand the PDU (CRC stripped) to the shared processor, - // which builds the response back into mb_frame. - mb_frame_len = (uint16_t)expected - 2; - process_mbpacket(); - - //Add CRC - //Check if response message is too big for this device - if (mb_frame_len + 2 > MAX_MB_FRAME) exceptionResponse(mb_frame[1], MB_EX_SLAVE_FAILURE); - mb_frame_len += 2; //increase frame length by two bytes to acomodate CRC - packet_crc = calcCrc(); //calculate CRC of the new packet - mb_frame[mb_frame_len - 2] = (uint8_t)(packet_crc >> 8); - mb_frame[mb_frame_len - 1] = (uint8_t)(packet_crc & 0x00FF); - - if (txpin >= 0) - { - digitalWrite(txpin, HIGH); - delayMicroseconds(mb_t35); - } - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (port == &Serial3) // RS485 serial port - Controllino_RS485TxEnable(); // Enable RS485 chip to transmit - #elif defined(CONTROLLINO_MICRO) - if (port == &Serial2) { - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, HIGH); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); - } - #endif - - port->write(mb_frame, mb_frame_len); - port->flush(); - delayMicroseconds(mb_t35); - - if (txpin >= 0) - digitalWrite(txpin, LOW); - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (port == &Serial3) // RS485 serial port - Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data - #elif defined(CONTROLLINO_MICRO) - if (port == &Serial2) { - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, LOW); - } - #endif - - // 5) The request — and the response built over it — consumed the whole - // assembly buffer. Modbus RTU is turn-taking: the master waits for - // this reply before sending its next request, so no following frame - // can already be buffered. Reset for the next request. A - // non-conformant pipelining master simply retransmits after its - // timeout, and the gap/realignment logic above recovers cleanly. - *plen = 0; - return; - } -} - -// Dispatch to one or two serial ports. Single-serial: the debugger and Modbus -// RTU (if any) share one port, assembled in-place in mb_frame. Dual-serial -// (MBSERIAL_ON_SECONDARY): the debugger keeps the default serial while Modbus -// RTU runs on a distinct UART — each with its own RX buffer. -void handle_serial() -{ -#ifdef MBSERIAL_ON_SECONDARY - handle_serial_port(&DEBUG_IFACE, -1, DEBUG_SLAVE, mb_rx_dbg, &mb_rx_dbg_len, &mb_rx_dbg_last_ms); - #ifdef MBSERIAL_TXPIN - handle_serial_port(&MBSERIAL_IFACE, MBSERIAL_TXPIN, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); - #else - handle_serial_port(&MBSERIAL_IFACE, -1, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); - #endif -#else - handle_serial_port(mb_serialport, mb_txpin, modbus.slaveid, mb_frame, &mb_rx_len, &mb_rx_last_ms); -#endif -} -#endif - - -void process_mbpacket() -{ - uint8_t fcode = mb_frame[1]; -#ifdef MODBUS_ENABLED - // Standard Modbus fields — only used by the operation FCs, which are - // compiled out in debug-only builds (so guard to avoid unused-var warnings). - uint16_t field1 = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; - uint16_t field2 = (uint16_t)mb_frame[4] << 8 | (uint16_t)mb_frame[5]; -#endif - void *endianness_check = &mb_frame[2]; - - switch (fcode) - { -#ifdef MODBUS_ENABLED - // Standard Modbus operation FCs read/write the coil/register buffers, - // which only exist when full Modbus is enabled. In debug-only builds - // these cases are compiled out, so operation requests fall through to - // the default and get an ILLEGAL_FUNCTION exception. - case MB_FC_WRITE_REG: - //field1 = reg, field2 = value - writeSingleRegister(field1, field2); - break; - - case MB_FC_READ_REGS: - //field1 = startreg, field2 = numregs - readRegisters(field1, field2); - break; - - case MB_FC_WRITE_REGS: - //field1 = startreg, field2 = status - writeMultipleRegisters(field1, field2, mb_frame[6]); - break; - - case MB_FC_READ_COILS: - //field1 = startreg, field2 = numregs - readCoils(field1, field2); - break; - - case MB_FC_READ_INPUT_STAT: - //field1 = startreg, field2 = numregs - readInputStatus(field1, field2); - break; - - case MB_FC_READ_INPUT_REGS: - //field1 = startreg, field2 = numregs - readInputRegisters(field1, field2); - break; - - case MB_FC_WRITE_COIL: - //field1 = reg, field2 = status - writeSingleCoil(field1, field2); - break; - - case MB_FC_WRITE_COILS: - //field1 = startreg, field2 = numoutputs - writeMultipleCoils(field1, field2, mb_frame[6]); - break; -#endif // MODBUS_ENABLED - - case MB_FC_DEBUG_INFO: - debugInfo(); - break; - - case MB_FC_DEBUG_GET: - { - // PDU: [FC:1][arr:u8][start_elem:u16][end_elem:u16] - uint8_t arr = mb_frame[2]; - uint16_t startIdx = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; - uint16_t endIdx = (uint16_t)mb_frame[5] << 8 | (uint16_t)mb_frame[6]; - debugGetTrace(arr, startIdx, endIdx); - } - break; - - case MB_FC_DEBUG_GET_LIST: - { - // PDU: [FC:1][count:u16][(arr:u8, elem:u16)×count] - uint16_t numIndexes = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; - debugGetTraceList(numIndexes, &mb_frame[4]); - } - break; - - case MB_FC_DEBUG_SET: - { - // PDU: [FC:1][arr:u8][elem:u16][force:u8][len:u16][value...] - uint8_t arr = mb_frame[2]; - uint16_t elem = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; - uint8_t flag = mb_frame[5]; - uint16_t len = (uint16_t)mb_frame[6] << 8 | (uint16_t)mb_frame[7]; - void *value = &mb_frame[8]; - debugSetTrace(arr, elem, flag, len, value); - } - break; - - case MB_FC_DEBUG_GET_MD5: - debugGetMd5(endianness_check); - break; - - case MB_FC_DEBUG_GET_STATUS: - debugGetStatus(); - break; - - case MB_FC_DEBUG_GET_VERSION: - debugGetVersion(); - break; - - case MB_FC_DEBUG_GET_BOARD_ID: - debugGetBoardId(); - break; - - default: - exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); - } -} - - -//Modbus handling functions -void readRegisters(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x007D) - { - exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg+numregs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //calculate the query reply message length - mb_frame_len = 3 + (numregs * 2); - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_REGS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_REGS; - mb_frame[2] = mb_frame_len - 3; //byte count - - uint16_t val; - uint16_t i = 0; - uint8_t pos = 0; - while(numregs--) - { - if ((startreg + i) < modbus.holding_size) - { - //retrieve the value from the register bank for the current register - val = modbus.holding[startreg + i]; - } - else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if ((startreg + i) % 2 == 0) //first word - { - pos = ((startreg + i) - modbus.holding_size) / 2; - val = (uint16_t)(modbus.dint_memory[pos] >> 16); - } - else //second word - { - pos = ((startreg + i) - modbus.holding_size - 1) / 2; - val = (uint16_t)(modbus.dint_memory[pos] & 0xffff); - } - } - else //64-bit registers - { - if ((startreg + i) % 4 == 0) //first word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - val = (uint16_t)(modbus.lint_memory[pos] >> 48); - } - else if ((startreg + i) % 4 == 1) //second word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - val = (uint16_t)((modbus.lint_memory[pos] >> 32) & 0xffff); - } - else if ((startreg + i) % 4 == 2) //third word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - val = (uint16_t)((modbus.lint_memory[pos] >> 16) & 0xffff); - } - else //fourth word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - val = (uint16_t)(modbus.lint_memory[pos] & 0xffff); - } - } - - //write the high byte of the register value - mb_frame[3 + (i * 2)] = val >> 8; - //write the low byte of the register value - mb_frame[4 + (i * 2)] = val & 0xFF; - i++; - } -} - -void writeSingleRegister(uint16_t reg, uint16_t value) -{ - if (reg >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_WRITE_REG, MB_EX_ILLEGAL_ADDRESS); - return; - } - - uint8_t pos = 0; - - if (reg < modbus.holding_size) - { - modbus.holding[reg] = value; - } - else if (reg < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if (reg % 2 == 0) //first word - { - pos = (reg - modbus.holding_size) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word - modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word - } - else //second word - { - pos = (reg - modbus.holding_size - 1) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; - modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; - } - - } - else //64-bit registers - { - if (reg % 4 == 0) //first word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word - } - else if (reg % 4 == 1) //second word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); - } - else if (reg % 4 == 2) //third word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); - } - else //fourth word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; - } - } -} - -void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount) -{ - //Check value - if (numoutputs < 0x0001 || numoutputs > 0x007B || bytecount != 2 * numoutputs) - { - exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address (startreg...startreg + numregs) - if ((startreg + numoutputs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Prepare answer frame buffer - mb_frame_len = 6; - mb_frame[1] = MB_FC_WRITE_REGS; - mb_frame[2] = startreg >> 8; - mb_frame[3] = startreg & 0x00FF; - mb_frame[4] = numoutputs >> 8; - mb_frame[5] = numoutputs & 0x00FF; - - uint16_t value; - uint16_t i = 0; - uint8_t pos = 0; - while(numoutputs--) - { - value = (uint16_t)mb_frame[7+i*2] << 8 | (uint16_t)mb_frame[8+i*2]; - - if ((startreg + i) < modbus.holding_size) - { - modbus.holding[(startreg + i)] = value; - } - else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if ((startreg + i) % 2 == 0) //first word - { - pos = ((startreg + i) - modbus.holding_size) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word - modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word - } - else //second word - { - pos = ((startreg + i) - modbus.holding_size - 1) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; - modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; - } - - } - else //64-bit registers - { - if ((startreg + i) % 4 == 0) //first word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word - } - else if ((startreg + i) % 4 == 1) //second word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); - } - else if ((startreg + i) % 4 == 2) //third word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); - } - else //fourth word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; - } - } - - i++; - } -} - -void exceptionResponse(uint16_t fcode, uint16_t excode) -{ - //Clean frame buffer (leave only SlaveID) - mb_frame_len = 3; - for (int i = 0; i < mb_frame_len; i++) mb_frame[i] = 0; - mb_frame[0] = modbus.slaveid; - mb_frame[1] = fcode + 0x80; - mb_frame[2] = excode; -} - -void readCoils(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x07D0) - { - exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if (startreg + numregs > modbus.coils_size) - { - exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Determine the message length = slaveid + function type + byte count and - //for each group of 8 registers the message length increases by 1 - mb_frame_len = 3 + numregs/8; - if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_COILS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_COILS; - mb_frame[2] = mb_frame_len - 3; //byte count (mb_frame_len - slave id, function code and byte count) - - uint8_t bitn = 0; - uint16_t totregs = numregs; - uint16_t i; - while (numregs) - { - i = (totregs - numregs--) / 8; - if (get_discrete((uint8_t)startreg, COILS)) - bitSet(mb_frame[3+i], bitn); - else - bitClear(mb_frame[3+i], bitn); - - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -void readInputStatus(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x07D0) - { - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg + numregs) > modbus.input_status_size) - { - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Determine the message length = function type, byte count and - //for each group of 8 registers the message length increases by 1 - mb_frame_len = 3 + numregs/8; - if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_INPUT_STAT; - mb_frame[2] = mb_frame_len - 3; - - byte bitn = 0; - uint16_t totregs = numregs; - uint16_t i; - while (numregs) - { - i = (totregs - numregs--) / 8; - if (get_discrete(startreg, INPUTSTATUS)) - bitSet(mb_frame[3+i], bitn); - else - bitClear(mb_frame[3+i], bitn); - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -void readInputRegisters(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x007D) - { - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg + numregs) > modbus.input_regs_size) - { - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //calculate the query reply message length - //for each register queried add 2 bytes - mb_frame_len = 3 + (numregs * 2); - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_INPUT_REGS; - mb_frame[2] = mb_frame_len - 3; - - uint16_t val; - uint16_t i = 0; - while(numregs--) - { - //retrieve the value from the register bank for the current register - val = modbus.input_regs[startreg + i]; - //write the high byte of the register value - mb_frame[3 + (i * 2)] = val >> 8; - //write the low byte of the register value - mb_frame[4 + (i * 2)] = val & 0xFF; - i++; - } -} - -void writeSingleCoil(uint16_t reg, uint16_t status) -{ - //Check value (status) - if (status != 0xFF00 && status != 0x0000) - { - exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if (reg > (modbus.coils_size - 1)) - { - exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Execute - write_discrete(reg, COILS, status == 0xFF00 ? true : false); -} - -void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount) -{ - //Check value - uint8_t bytecount_calc = numoutputs / 8; - if (numoutputs%8) bytecount_calc++; - if (numoutputs < 0x0001 || numoutputs > 0x07B0 || bytecount != bytecount_calc) - { - exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address (startreg...startreg + numregs) - if ((startreg + numoutputs) > modbus.coils_size) - { - exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Prepare answer frame buffer - mb_frame_len = 6; - mb_frame[1] = MB_FC_WRITE_COILS; - mb_frame[2] = startreg >> 8; - mb_frame[3] = startreg & 0x00FF; - mb_frame[4] = numoutputs >> 8; - mb_frame[5] = numoutputs & 0x00FF; - - //Execute - uint8_t bitn = 0; - uint16_t totoutputs = numoutputs; - uint16_t i; - while (numoutputs) - { - i = (totoutputs - numoutputs--) / 8; - write_discrete(startreg, COILS, bitRead(mb_frame[7+i], bitn)); - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_INFO function code. - * - * This function constructs a Modbus response frame for the DEBUG_INFO function code. - * The response frame includes the number of variables defined in the PLC program. - * - * Modbus Response Frame (DEBUG_INFO): - * +-----+-------+-------+ - * | MB | Count | Count | - * | FC | | | - * +-----+-------+-------+ - * |0x41 | High | Low | - * | | Byte | Byte | - * | | | | - * +-----+-------+-------+ - * - * @return void - */ -// Phase 4 PDU: -// +-----+-------+------+-----------+-----------+-----------+ -// | FC | arrs | stat | count_0 | count_1 | ... | -// |0x41 | (u8) | (u8) | (u16 BE) | (u16 BE) | | -// +-----+-------+------+-----------+-----------+-----------+ -// Response: [FC, arrCount, STATUS_OK, (count×arrCount as u16 BE)] -void debugInfo() -{ - uint8_t arrCount = openplc_debug_array_count(); - - // Cap at what the Modbus frame can hold: 3 header bytes + 2 bytes/array. - // Realistic projects have <=10 arrays, so this is never a real limit. - uint8_t maxArrs = (MAX_MB_FRAME - 3) / 2; - if (arrCount > maxArrs) arrCount = maxArrs; - - mb_frame[1] = MB_FC_DEBUG_INFO; - mb_frame[2] = arrCount; - mb_frame[3] = MB_DEBUG_SUCCESS; - uint16_t pos = 4; - for (uint8_t i = 0; i < arrCount; i++) - { - uint16_t c = openplc_debug_elem_count(i); - mb_frame[pos++] = (uint8_t)(c >> 8); - mb_frame[pos++] = (uint8_t)(c & 0xFF); - } - mb_frame_len = pos; -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_SET function code. - * - * This function constructs a Modbus response frame for the DEBUG_SET function code. - * The response frame indicates whether the set trace command was successful or if - * there was an error, such as an out-of-bounds index. - * - * Modbus Response Frame (DEBUG_SET): - * +-----+------+ - * | MB | Resp.| - * | FC | Code | - * +-----+------+ - * |0x42 | Code | - * +-----+------+ - * - * @param varidx The index of the variable to set trace for. - * @param flag The trace flag. - * @param len The length of the trace data. - * @param value Pointer to the trace data. - * - * @return void - */ -// Phase 4 PDU: [FC, arr, elem_hi, elem_lo, force, len_hi, len_lo, value...] -// Response: [FC, STATUS] -void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, - uint16_t len, void *value) -{ - if (len > (MAX_MB_FRAME - 8)) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_SET; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; - return; - } - - uint8_t status = openplc_debug_set( - arr, elem, (uint8_t)flag, (const uint8_t *)value, len); - - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_SET; - mb_frame[2] = status; -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_GET function code. - * - * This function constructs a Modbus response frame for the DEBUG_GET function code. - * The response frame includes the trace data for variables within the specified index range. - * - * Modbus Response Frame (DEBUG_GET): - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | - * | FC | Code | Index | Index | | | | | Size | Size | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | - * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * - * @param startidx The start index of the variables to get trace for. - * @param endidx The end index of the variables to get trace for. - * - * @return void - */ -// Phase 4 PDU: [FC, arr, start_hi, start_lo, end_hi, end_lo] -// Response: [FC, STATUS, last_elem_hi, last_elem_lo, -// tick_hi, tick_mh, tick_ml, tick_lo, -// size_hi, size_lo, data...] -void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) -{ - uint16_t arrCount = openplc_debug_elem_count(arr); - if (arrCount == 0 || startidx >= arrCount || - endidx >= arrCount || startidx > endidx) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_GET; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; - return; - } - - uint16_t lastElemIdx = startidx; - uint16_t responseSize = 0; - uint8_t *responsePtr = &(mb_frame[11]); - - for (uint16_t elem = startidx; elem <= endidx; elem++) - { - uint16_t varSize = openplc_debug_size(arr, elem); - // Bounds check — stop packing if this one won't fit. - if ((11 + responseSize + varSize) > MAX_MB_FRAME) break; - if (varSize == 0) { - // Entry has no readable bytes (string stub / out-of-bounds) - // — skip gracefully to keep the scan progressing. - lastElemIdx = elem; - continue; - } - uint16_t n = openplc_debug_read(arr, elem, responsePtr); - if (n == 0) { - lastElemIdx = elem; - continue; - } - responsePtr += n; - responseSize += n; - lastElemIdx = elem; - } - - mb_frame_len = 11 + responseSize; - mb_frame[1] = MB_FC_DEBUG_GET; - mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = (uint8_t)(lastElemIdx >> 8); - mb_frame[4] = (uint8_t)(lastElemIdx & 0xFF); - mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); - mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); - mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); - mb_frame[8] = (uint8_t)(scan_counter & 0xFF); - mb_frame[9] = (uint8_t)(responseSize >> 8); - mb_frame[10] = (uint8_t)(responseSize & 0xFF); -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_GET_LIST function code. - * - * This function constructs a Modbus response frame for the DEBUG_GET_LIST function code. - * The response frame includes the trace data for variables specified in the provided index list. - * - * Modbus Response Frame (DEBUG_GET_LIST): - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | - * | FC | Code | Index | Index | | | | | Size | Size | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | - * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * - * @param numIndexes The number of indexes requested. - * @param indexArray Pointer to the array containing variable indexes. - * - * @return void - */ -// Phase 4 PDU: [FC, count_hi, count_lo, (arr:u8, elem_hi, elem_lo)×count] -// Response: [FC, STATUS, last_idx_hi, last_idx_lo, -// tick_hi, tick_mh, tick_ml, tick_lo, -// size_hi, size_lo, data...] -// last_idx is the index *into the request list* that was last successfully -// included — the editor uses it to retry from the next item on overflow. -void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) -{ - uint16_t response_idx = 11; - uint16_t responseSize = 0; - uint16_t lastReqIdx = 0; - - #ifdef MB_SERIAL_ACTIVE - #define VARIDX_SIZE 20 - #else - #define VARIDX_SIZE 60 - #endif - - if (numIndexes > VARIDX_SIZE) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_GET_LIST; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_MEMORY; - return; - } - - // The request indexArray (at mb_frame[4..]) and the response buffer - // (mb_frame[11..]) overlap. Once handle_read writes the first response - // byte, later index entries inside mb_frame are clobbered. Snapshot the - // request first. - uint8_t localIndex[VARIDX_SIZE * 3]; - for (uint16_t i = 0; i < numIndexes * 3; i++) { - localIndex[i] = indexArray[i]; - } - - // Each address pair is 3 bytes: [arr:u8, elem_hi, elem_lo] - for (uint16_t i = 0; i < numIndexes; i++) - { - uint8_t arr = localIndex[i * 3]; - uint16_t elem = (uint16_t)localIndex[i * 3 + 1] << 8 | - (uint16_t)localIndex[i * 3 + 2]; - - uint16_t varSize = openplc_debug_size(arr, elem); - if (varSize == 0) - { - // Out-of-bounds or string stub — skip gracefully. - lastReqIdx = i; - continue; - } - if ((response_idx + varSize) > MAX_MB_FRAME) break; - - uint16_t n = openplc_debug_read(arr, elem, &mb_frame[response_idx]); - if (n == 0) - { - lastReqIdx = i; - continue; - } - response_idx += n; - responseSize += n; - lastReqIdx = i; - } - - mb_frame_len = response_idx; - mb_frame[1] = MB_FC_DEBUG_GET_LIST; - mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = (uint8_t)(lastReqIdx >> 8); - mb_frame[4] = (uint8_t)(lastReqIdx & 0xFF); - mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); - mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); - mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); - mb_frame[8] = (uint8_t)(scan_counter & 0xFF); - mb_frame[9] = (uint8_t)(responseSize >> 8); - mb_frame[10] = (uint8_t)(responseSize & 0xFF); -} - -// PDU request: [FC, endian_check_hi, endian_check_lo] -// PDU response: [FC, STATUS, md5_ascii..., endian_marker_hi, endian_marker_lo] -// -// The target always writes variable data in native byte order — STruC++ does -// no server-side byte-order adaptation, force/read is pure memcpy. To let -// the editor detect what "native" means here, the MD5 response trailer -// writes the literal value 0xDEAD via a native `uint16_t*` store. The -// bytes that land in the response are therefore in the target's native -// byte order: -// -// LE target → trailer bytes = [0xAD, 0xDE] -// BE target → trailer bytes = [0xDE, 0xAD] -// -// The editor inspects those two bytes after MD5 verification and decides -// whether subsequent force/read traffic needs byte-swapping at its end. -// -// The probe bytes the editor sends are intentionally ignored — the trailer -// is a runtime-driven sentinel, not an echo. The argument stays in the -// signature for ABI compatibility with the dispatcher. -void debugGetMd5(void * /*endianness*/) -{ - mb_frame[1] = MB_FC_DEBUG_GET_MD5; - mb_frame[2] = MB_DEBUG_SUCCESS; - const char md5[] = PROGRAM_MD5; - int md5_len = 0; - for (md5_len = 0; md5[md5_len] != '\0'; md5_len++) - { - mb_frame[md5_len + 3] = md5[md5_len]; - } +// Serial transport (mbconfig_serial_iface, handle_serial/handle_serial_port, +// mb_rtu_drop_front, RX-assembly buffers, RS485 timing) moved to modbus_serial.cpp. - // Native-order store of the endianness sentinel. Written byte-wise - // (not via `*reinterpret_cast`) because `md5_len + 3` is an - // odd offset for a 32-char MD5, and a typed 16-bit store there is an - // unaligned access that HardFaults on Cortex-M0+ (SAMD21: MKR Zero / - // P1AM-100) — hanging the device on the first debugger request. Copying - // the two bytes of a native-order uint16_t preserves the target's byte - // ordering (the signal the editor uses to choose its swap behaviour) - // while keeping every access byte-aligned. - const uint16_t endian_sentinel = 0xDEAD; - const uint8_t *sentinel_bytes = reinterpret_cast(&endian_sentinel); - mb_frame[md5_len + 3] = sentinel_bytes[0]; - mb_frame[md5_len + 4] = sentinel_bytes[1]; - mb_frame_len = md5_len + 5; -} - -// PDU request: [FC] -// PDU response: [FC, STATUS, running:u8, tick:u32 BE, uptime_ms:u32 BE] -// -// Lightweight liveness/diagnostic probe that does not require a full debug -// session. `running` is always 1 on baremetal (the PLC scan is unconditional); -// `tick` is the scan counter (same value the read FCs report), so a client can -// tell whether the PLC is actually cycling by watching it advance. `uptime_ms` -// is millis() since boot. -void debugGetStatus() -{ - uint32_t uptime = (uint32_t)millis(); - - mb_frame[1] = MB_FC_DEBUG_GET_STATUS; - mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = 1; // PLC scan is always running on baremetal - mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); - mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); - mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); - mb_frame[7] = (uint8_t)(scan_counter & 0xFF); - mb_frame[8] = (uint8_t)((uptime >> 24) & 0xFF); - mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); - mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); - mb_frame[11] = (uint8_t)(uptime & 0xFF); - mb_frame_len = 12; -} - -// PDU request: [FC] -// PDU response: [FC, STATUS, version_ascii...] (no NUL terminator) -// -// Reports OPENPLC_RUNTIME_VERSION (defined in openplc_version.h). The editor -// reads the ASCII bytes up to the end of the frame. -void debugGetVersion() -{ - mb_frame[1] = MB_FC_DEBUG_GET_VERSION; - mb_frame[2] = MB_DEBUG_SUCCESS; - const char ver[] = OPENPLC_RUNTIME_VERSION; - uint16_t i = 0; - for (i = 0; ver[i] != '\0'; i++) - { - if ((uint16_t)(3 + i) >= MAX_MB_FRAME) break; // never overrun the frame - mb_frame[3 + i] = (uint8_t)ver[i]; - } - mb_frame_len = 3 + i; -} +// process_mbpacket() + mb_pdu_request_len() + mb_pdu_skips_crc() moved to modbus_pdu.cpp. -// PDU request: [FC] -// PDU response: [FC, STATUS, id_len:u8, id_bytes...] -// -// Returns the unique hardware ID via ArduinoUniqueID. id_len is UniqueIDsize -// (architecture-dependent: AVR 9-10, ESP8266 4, ESP32 6, SAM/SAMD 16, STM32 -// 12, Teensy 8). On a core without support, id_len = 0 and no bytes follow. -void debugGetBoardId() -{ - mb_frame[1] = MB_FC_DEBUG_GET_BOARD_ID; - mb_frame[2] = MB_DEBUG_SUCCESS; -#ifdef OPENPLC_HAS_UNIQUE_ID - uint8_t idLen = (uint8_t)UniqueIDsize; - // Clamp so [FC][STATUS][id_len][id_bytes...] always fits the frame. - if ((uint16_t)(4 + idLen) > MAX_MB_FRAME) idLen = (uint8_t)(MAX_MB_FRAME - 4); - mb_frame[3] = idLen; - for (uint8_t i = 0; i < idLen; i++) - mb_frame[4 + i] = UniqueID[i]; - mb_frame_len = 4 + idLen; -#else - mb_frame[3] = 0; // no unique-id support on this core - mb_frame_len = 4; -#endif -} +// Register store + operation FCs (readRegisters..writeMultipleCoils) moved to modbus_registers.cpp. -uint16_t calcCrc() -{ - uint8_t CRCHi = 0xFF, CRCLo = 0x0FF, Index; +// Debugger FCs (debugInfo/debugSetTrace/debugGetTrace/debugGetTraceList/debugGetMd5/ +// debugGetStatus/debugGetVersion/debugGetBoardId) moved to modbus_debug.cpp. - int i = 0; - Index = CRCHi ^ mb_frame[i]; - CRCHi = CRCLo ^ _auchCRCHi[Index]; - CRCLo = _auchCRCLo[Index]; - i++; - - while (i < (mb_frame_len - 2)) - { - Index = CRCHi ^ mb_frame[i]; - i++; - CRCHi = CRCLo ^ _auchCRCHi[Index]; - CRCLo = _auchCRCLo[Index]; - } - - return ((uint16_t)CRCHi << 8) | (uint16_t)CRCLo; -} +// calcCrc() and the CRC lookup tables moved to modbus_crc.cpp. diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 506d9c0f7..d7f3b5c80 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -7,88 +7,25 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #define MODBUSSLAVE_H #include -#include "defines.h" #include "openplc_version.h" - -// Serial transport is active when full Modbus RTU (MBSERIAL) is enabled OR the -// always-on debugger (DEBUGGER_ENABLED) needs the serial port without the rest -// of Modbus. This gate guards the serial RX/framing code so the debugger works -// over serial even when no Modbus operation buffers (coils/holding/etc.) are -// allocated. -#if defined(MBSERIAL) || defined(DEBUGGER_ENABLED) - #define MB_SERIAL_ACTIVE -#endif - -// Default serial config for the always-on debugger. `defines.h` normally emits -// DEBUG_IFACE / DEBUG_BAUD explicitly (from the Serial screen); these `#ifndef` -// defaults cover anything it left unset. Defined whenever the debugger is on — -// including alongside full Modbus RTU, since the dual-serial path (RTU on a -// secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. -#ifdef DEBUGGER_ENABLED - #ifndef DEBUG_IFACE - #define DEBUG_IFACE Serial - #endif - #ifndef DEBUG_BAUD - #define DEBUG_BAUD 115200 - #endif - #ifndef DEBUG_SLAVE - #define DEBUG_SLAVE 1 - #endif -#endif - -#ifndef bitRead - #define bitRead(value, bit) (((value) >> (bit)) & 0x01) -#endif -//#define bitSet(value, bit) ((value) |= (1UL << (bit))) -//#define bitClear(value, bit) ((value) &= ~(1UL << (bit))) -#ifndef bitWrite - #define bitWrite(value, bit, bitvalue) (bitvalue ? bitSet(value, bit) : bitClear(value, bit)) -#endif -#define COILS 0 -#define INPUTSTATUS 1 -#if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega168__) || defined(__AVR_ATmega32U4__) || defined(__AVR_ATmega16U4__) - #define MAX_MB_FRAME 128 -#else - #define MAX_MB_FRAME 256 -#endif -#define MAX_SRV_CLIENTS 3 //how many clients should be able to connect to TCP server at the same time -#define MBAP_SIZE 6 - -//Platform specific defines and includes -#ifdef MBTCP_ETHERNET -#include -#ifdef BOARD_ESP32 - // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) - #define ETH_PHY_ADDR 0 // DEFAULT VALUE IS 0 YOU CAN OMIT IT - // Type of the Ethernet PHY (LAN8720 or TLK110) - #define ETH_PHY_TYPE ETH_PHY_LAN8720 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) - #define ETH_PHY_POWER -1 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the I²C clock signal for the Ethernet PHY - #define ETH_PHY_MDC 23 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the I²C IO signal for the Ethernet PHY - #define ETH_PHY_MDIO 18 // DEFAULT VALUE YOU CAN OMIT IT - // External clock from crystal oscillator - #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN // DEFAULT VALUE YOU CAN OMIT IT - #include - #include -#else - #include -#endif -#endif - -#ifdef MBTCP_WIFI -#if defined(BOARD_ESP8266) -#include -#elif defined(BOARD_ESP32) -#include -#elif defined(BOARD_WIFININA) -#include -#else -#include -#include -#endif -#endif +// modbus_types.h pulls modbus_config.h, which brings defines.h and the composite +// build gates (MB_SERIAL_ACTIVE, DEBUG_* defaults). defines.h has no include +// guard, so it is deliberately NOT included directly here — only via that path. +#include "modbus_types.h" +#include "modbus_frame.h" +#include "modbus_crc.h" +#include "modbus_registers.h" +#include "modbus_debug.h" +#include "modbus_pdu.h" +#include "modbus_serial.h" +#include "modbus_tcp.h" + +// Shared type/constant declarations (enums, MBinfo, MAX_MB_FRAME, MBAP_SIZE, +// COILS/INPUTSTATUS, bit helpers, MB_DEBUG_* status codes) live in modbus_types.h; +// the build gates above come from modbus_config.h — both included above. + +// The TCP platform includes (SPI/Ethernet/WiFi/ETH + ESP32 PHY defines) and the +// TCP server state now live in modbus_tcp.h (included above). #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) #include "Controllino.h" @@ -99,169 +36,23 @@ Copyright (C) 2022 OpenPLC - Thiago Alves // file deliberately does NOT redeclare it (a second declaration would // conflict with the C-linkage one and break the build). -// Status codes (match strucpp::debug::STATUS_* in debug_dispatch.hpp, kept -// as macros here so the Modbus layer doesn't have to include the C++ -// runtime header when the rest of the protocol is C-style). -#define MB_DEBUG_SUCCESS 0x7E -#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 -#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 - -//Modbus registers struct -struct MBinfo { - uint8_t slaveid; - uint16_t *holding; - uint8_t holding_size; - uint32_t *dint_memory; - uint8_t dint_memory_size; - uint64_t *lint_memory; - uint8_t lint_memory_size; - uint8_t *coils; - uint8_t coils_size; - uint16_t *input_regs; - uint8_t input_regs_size; - uint8_t *input_status; - uint8_t input_status_size; -}; - -//Function Codes -enum { - MB_FC_READ_COILS = 0x01, // Read Coils (Output) Status 0xxxx - MB_FC_READ_INPUT_STAT = 0x02, // Read Input Status (Discrete Inputs) 1xxxx - MB_FC_READ_REGS = 0x03, // Read Holding Registers 4xxxx - MB_FC_READ_INPUT_REGS = 0x04, // Read Input Registers 3xxxx - MB_FC_WRITE_COIL = 0x05, // Write Single Coil (Output) 0xxxx - MB_FC_WRITE_REG = 0x06, // Preset Single Register 4xxxx - MB_FC_WRITE_COILS = 0x0F, // Write Multiple Coils (Outputs) 0xxxx - MB_FC_WRITE_REGS = 0x10, // Write block of contiguous registers 4xxxx - MB_FC_DEBUG_INFO = 0x41, // Request debug variables count - MB_FC_DEBUG_SET = 0x42, // Debug set trace (force variable) - MB_FC_DEBUG_GET = 0x43, // Debug get trace (read variables) - MB_FC_DEBUG_GET_LIST = 0x44, // Debug get trace list (read list of variables) - MB_FC_DEBUG_GET_MD5 = 0x45, // Debug get current program MD5 - MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) - MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version - MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID -}; +// MBinfo, the MB_FC_* / MB_EX_* enums and the MB_DEBUG_* status codes now live +// in modbus_types.h (included above). The shared frame seam (mb_frame, +// mb_frame_len, the `modbus` instance and exceptionResponse) lives in +// modbus_frame.h (included above). -//Exception Codes -enum { - MB_EX_ILLEGAL_FUNCTION = 0x01, // Function Code not Supported - MB_EX_ILLEGAL_ADDRESS = 0x02, // Output Address not exists - MB_EX_ILLEGAL_VALUE = 0x03, // Output Value not in Range - MB_EX_SLAVE_FAILURE = 0x04, // Slave Device Fails to process request -}; +// The serial port/timing globals (mb_serialport/mb_txpin/mb_t15/mb_t35) live in +// modbus_serial.h; the TCP server state (mb_server/mb_serverClients/mb_mbap) and +// mbconfig_ethernet_iface()/handle_tcp() in modbus_tcp.h — both included above. -//Global Modbus vars -extern struct MBinfo modbus; -extern uint8_t mb_frame[MAX_MB_FRAME]; -extern uint16_t mb_frame_len; -extern Stream* mb_serialport; -extern int8_t mb_txpin; -extern uint16_t mb_t15; // inter character time out -extern uint16_t mb_t35; // frame delay - -#ifdef MBTCP_ETHERNET -#ifdef BOARD_ESP32 - extern WiFiServer mb_server; -#else - extern EthernetServer mb_server; -#endif - extern uint8_t mb_mbap[MBAP_SIZE]; -#ifdef BOARD_PORTENTA - extern EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -#ifdef MBTCP_WIFI - extern WiFiServer mb_server; - extern uint8_t mb_mbap[MBAP_SIZE]; -#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) - extern WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus); -bool get_discrete(uint16_t addr, bool regtype); -void write_discrete(uint16_t addr, bool regtype, bool value); -void mbconfig_serial_iface(Stream* port, long baud, int txPin); -#ifdef MBTCP -void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet); -#endif void mbtask(); -#ifdef MBTCP -void handle_tcp(); -#endif -#ifdef MB_SERIAL_ACTIVE -void handle_serial(); -#endif -void process_mbpacket(); -uint16_t calcCrc(); - -//Modbus handling functions -void readRegisters(uint16_t startreg, uint16_t numregs); -void writeSingleRegister(uint16_t reg, uint16_t value); -void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount); -void exceptionResponse(uint16_t fcode, uint16_t excode); -void readCoils(uint16_t startreg, uint16_t numregs); -void readInputStatus(uint16_t startreg, uint16_t numregs); -void readInputRegisters(uint16_t startreg, uint16_t numregs); -void writeSingleCoil(uint16_t reg, uint16_t status); -void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount); -// Phase 4 debugger entrypoints. Signatures changed from MatIEC-era -// (flat u16 index) to the (array_idx: u8, elem_idx: u16) addressing model. -void debugInfo(void); -void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, - uint16_t len, void *value); -void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx); -void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray); -void debugGetMd5(void *endianness); -// Always-on debugger extras — served even without full Modbus (DEBUGGER_ENABLED). -void debugGetStatus(void); -void debugGetVersion(void); -void debugGetBoardId(void); - - -/* Table of CRC values for high-order byte */ -const byte _auchCRCHi[] = { - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, - 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, - 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40}; - -/* Table of CRC values for low-order byte */ -const byte _auchCRCLo[] = { - 0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06, 0x07, 0xC7, 0x05, 0xC5, 0xC4, - 0x04, 0xCC, 0x0C, 0x0D, 0xCD, 0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09, - 0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A, 0x1E, 0xDE, 0xDF, 0x1F, 0xDD, - 0x1D, 0x1C, 0xDC, 0x14, 0xD4, 0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3, - 0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3, 0xF2, 0x32, 0x36, 0xF6, 0xF7, - 0x37, 0xF5, 0x35, 0x34, 0xF4, 0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A, - 0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29, 0xEB, 0x2B, 0x2A, 0xEA, 0xEE, - 0x2E, 0x2F, 0xEF, 0x2D, 0xED, 0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26, - 0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60, 0x61, 0xA1, 0x63, 0xA3, 0xA2, - 0x62, 0x66, 0xA6, 0xA7, 0x67, 0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F, - 0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68, 0x78, 0xB8, 0xB9, 0x79, 0xBB, - 0x7B, 0x7A, 0xBA, 0xBE, 0x7E, 0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5, - 0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71, 0x70, 0xB0, 0x50, 0x90, 0x91, - 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C, - 0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B, 0x99, 0x59, 0x58, 0x98, 0x88, - 0x48, 0x49, 0x89, 0x4B, 0x8B, 0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C, - 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, - 0x40}; +// mbconfig_serial_iface() and handle_serial() live in modbus_serial.h; +// process_mbpacket() and the per-FC frame-shape helpers (mb_pdu_request_len, +// mb_pdu_skips_crc) live in modbus_pdu.h; the register store and operation FCs +// (init_mbregs, get/write_discrete, readRegisters..writeMultipleCoils) in +// modbus_registers.h; the debugger FCs (debugInfo..debugGetBoardId) in +// modbus_debug.h; calcCrc() and the CRC tables in modbus_crc.{h,cpp} — all +// included above. #endif diff --git a/resources/sources/Baremetal/modbus_config.h b/resources/sources/Baremetal/modbus_config.h new file mode 100644 index 000000000..950869cf7 --- /dev/null +++ b/resources/sources/Baremetal/modbus_config.h @@ -0,0 +1,46 @@ +/* +modbus_config.h - Build configuration for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves + +The single place every Modbus translation unit picks up its build gates from. +It pulls in the generated defines.h (MODBUS_ENABLED / MBSERIAL / DEBUGGER_ENABLED +/ MBTCP / MBSERIAL_ON_SECONDARY / ...) and derives the composite gates on top of +it. Because the modularized TUs are gated (e.g. modbus_registers.cpp is wrapped +in #ifdef MODBUS_ENABLED), EACH of them must see these macros — so every modbus_* +header includes this one (via modbus_types.h). defines.h has no include guard, so +it must reach a TU through exactly one path: this header. +*/ + +#ifndef MODBUS_CONFIG_H +#define MODBUS_CONFIG_H + +#include +#include "defines.h" + +// Serial transport is active when full Modbus RTU (MBSERIAL) is enabled OR the +// always-on debugger (DEBUGGER_ENABLED) needs the serial port without the rest +// of Modbus. This gate guards the serial RX/framing code so the debugger works +// over serial even when no Modbus operation buffers (coils/holding/etc.) are +// allocated. +#if defined(MBSERIAL) || defined(DEBUGGER_ENABLED) + #define MB_SERIAL_ACTIVE +#endif + +// Default serial config for the always-on debugger. `defines.h` normally emits +// DEBUG_IFACE / DEBUG_BAUD explicitly (from the Serial screen); these `#ifndef` +// defaults cover anything it left unset. Defined whenever the debugger is on — +// including alongside full Modbus RTU, since the dual-serial path (RTU on a +// secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. +#ifdef DEBUGGER_ENABLED + #ifndef DEBUG_IFACE + #define DEBUG_IFACE Serial + #endif + #ifndef DEBUG_BAUD + #define DEBUG_BAUD 115200 + #endif + #ifndef DEBUG_SLAVE + #define DEBUG_SLAVE 1 + #endif +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_crc.cpp b/resources/sources/Baremetal/modbus_crc.cpp new file mode 100644 index 000000000..909836620 --- /dev/null +++ b/resources/sources/Baremetal/modbus_crc.cpp @@ -0,0 +1,72 @@ +/* +modbus_crc.cpp - Modbus RTU CRC-16 for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_crc.h" +// mb_frame / mb_frame_len come from the shared frame TU. Until T3 of the +// modularization they are declared in ModbusSlave.h; afterwards in modbus_frame.h. +#include "ModbusSlave.h" + +/* Table of CRC values for high-order byte */ +static const byte _auchCRCHi[] = { + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, + 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, + 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40}; + +/* Table of CRC values for low-order byte */ +static const byte _auchCRCLo[] = { + 0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06, 0x07, 0xC7, 0x05, 0xC5, 0xC4, + 0x04, 0xCC, 0x0C, 0x0D, 0xCD, 0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09, + 0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A, 0x1E, 0xDE, 0xDF, 0x1F, 0xDD, + 0x1D, 0x1C, 0xDC, 0x14, 0xD4, 0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3, + 0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3, 0xF2, 0x32, 0x36, 0xF6, 0xF7, + 0x37, 0xF5, 0x35, 0x34, 0xF4, 0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A, + 0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29, 0xEB, 0x2B, 0x2A, 0xEA, 0xEE, + 0x2E, 0x2F, 0xEF, 0x2D, 0xED, 0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26, + 0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60, 0x61, 0xA1, 0x63, 0xA3, 0xA2, + 0x62, 0x66, 0xA6, 0xA7, 0x67, 0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F, + 0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68, 0x78, 0xB8, 0xB9, 0x79, 0xBB, + 0x7B, 0x7A, 0xBA, 0xBE, 0x7E, 0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5, + 0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71, 0x70, 0xB0, 0x50, 0x90, 0x91, + 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C, + 0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B, 0x99, 0x59, 0x58, 0x98, 0x88, + 0x48, 0x49, 0x89, 0x4B, 0x8B, 0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C, + 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, + 0x40}; + +uint16_t calcCrc() +{ + uint8_t CRCHi = 0xFF, CRCLo = 0x0FF, Index; + + int i = 0; + Index = CRCHi ^ mb_frame[i]; + CRCHi = CRCLo ^ _auchCRCHi[Index]; + CRCLo = _auchCRCLo[Index]; + i++; + + while (i < (mb_frame_len - 2)) + { + Index = CRCHi ^ mb_frame[i]; + i++; + CRCHi = CRCLo ^ _auchCRCHi[Index]; + CRCLo = _auchCRCLo[Index]; + } + + return ((uint16_t)CRCHi << 8) | (uint16_t)CRCLo; +} diff --git a/resources/sources/Baremetal/modbus_crc.h b/resources/sources/Baremetal/modbus_crc.h new file mode 100644 index 000000000..0655295da --- /dev/null +++ b/resources/sources/Baremetal/modbus_crc.h @@ -0,0 +1,18 @@ +/* +modbus_crc.h - Modbus RTU CRC-16 for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#ifndef MODBUS_CRC_H +#define MODBUS_CRC_H + +#include "modbus_types.h" + +// CRC-16 (Modbus) over mb_frame[0 .. mb_frame_len-2] — i.e. the whole frame +// except the trailing two CRC bytes. Reads the shared frame globals +// (mb_frame / mb_frame_len, declared in modbus_frame.h). The lookup tables are +// defined once in modbus_crc.cpp (they used to sit in the header, which risked +// one flash copy per translation unit). +uint16_t calcCrc(); + +#endif diff --git a/resources/sources/Baremetal/modbus_debug.cpp b/resources/sources/Baremetal/modbus_debug.cpp new file mode 100644 index 000000000..d6d586285 --- /dev/null +++ b/resources/sources/Baremetal/modbus_debug.cpp @@ -0,0 +1,397 @@ +/* +modbus_debug.cpp - OpenPLC always-on debugger function codes (0x41-0x48) +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_debug.h" +// Debug surface comes via the extern "C" shims in arduino_runtime_glue.h +// (openplc_debug_*, scan_counter) so this TU stays free of strucpp's +// template-heavy headers and compiles cleanly under arduino-cli's default C++ +// standard. The shims forward to strucpp::debug::handle_* inside +// arduino_runtime_glue.cpp (part of the precompiled OpenPLCUserLib archive). +#include "arduino_runtime_glue.h" +#include "openplc_version.h" + +// ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. +// It supports AVR/megaAVR/SAM/SAMD/STM32/ESP/RP2040/Teensy. On a core without +// support (or when a board intentionally opts out via OPENPLC_NO_UNIQUE_ID), +// the board-id handler returns id_len = 0 instead of failing to compile. +#ifndef OPENPLC_NO_UNIQUE_ID + #include + #define OPENPLC_HAS_UNIQUE_ID +#endif + +/** + * @brief Sends a Modbus response frame for the DEBUG_INFO function code. + * + * This function constructs a Modbus response frame for the DEBUG_INFO function code. + * The response frame includes the number of variables defined in the PLC program. + * + * Modbus Response Frame (DEBUG_INFO): + * +-----+-------+-------+ + * | MB | Count | Count | + * | FC | | | + * +-----+-------+-------+ + * |0x41 | High | Low | + * | | Byte | Byte | + * | | | | + * +-----+-------+-------+ + * + * @return void + */ +// Phase 4 PDU: +// +-----+-------+------+-----------+-----------+-----------+ +// | FC | arrs | stat | count_0 | count_1 | ... | +// |0x41 | (u8) | (u8) | (u16 BE) | (u16 BE) | | +// +-----+-------+------+-----------+-----------+-----------+ +// Response: [FC, arrCount, STATUS_OK, (count×arrCount as u16 BE)] +void debugInfo() +{ + uint8_t arrCount = openplc_debug_array_count(); + + // Cap at what the Modbus frame can hold: 3 header bytes + 2 bytes/array. + // Realistic projects have <=10 arrays, so this is never a real limit. + uint8_t maxArrs = (MAX_MB_FRAME - 3) / 2; + if (arrCount > maxArrs) arrCount = maxArrs; + + mb_frame[1] = MB_FC_DEBUG_INFO; + mb_frame[2] = arrCount; + mb_frame[3] = MB_DEBUG_SUCCESS; + uint16_t pos = 4; + for (uint8_t i = 0; i < arrCount; i++) + { + uint16_t c = openplc_debug_elem_count(i); + mb_frame[pos++] = (uint8_t)(c >> 8); + mb_frame[pos++] = (uint8_t)(c & 0xFF); + } + mb_frame_len = pos; +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_SET function code. + * + * This function constructs a Modbus response frame for the DEBUG_SET function code. + * The response frame indicates whether the set trace command was successful or if + * there was an error, such as an out-of-bounds index. + * + * Modbus Response Frame (DEBUG_SET): + * +-----+------+ + * | MB | Resp.| + * | FC | Code | + * +-----+------+ + * |0x42 | Code | + * +-----+------+ + * + * @param varidx The index of the variable to set trace for. + * @param flag The trace flag. + * @param len The length of the trace data. + * @param value Pointer to the trace data. + * + * @return void + */ +// Phase 4 PDU: [FC, arr, elem_hi, elem_lo, force, len_hi, len_lo, value...] +// Response: [FC, STATUS] +void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, + uint16_t len, void *value) +{ + if (len > (MAX_MB_FRAME - 8)) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_SET; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + uint8_t status = openplc_debug_set( + arr, elem, (uint8_t)flag, (const uint8_t *)value, len); + + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_SET; + mb_frame[2] = status; +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_GET function code. + * + * This function constructs a Modbus response frame for the DEBUG_GET function code. + * The response frame includes the trace data for variables within the specified index range. + * + * Modbus Response Frame (DEBUG_GET): + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | + * | FC | Code | Index | Index | | | | | Size | Size | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | + * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * + * @param startidx The start index of the variables to get trace for. + * @param endidx The end index of the variables to get trace for. + * + * @return void + */ +// Phase 4 PDU: [FC, arr, start_hi, start_lo, end_hi, end_lo] +// Response: [FC, STATUS, last_elem_hi, last_elem_lo, +// tick_hi, tick_mh, tick_ml, tick_lo, +// size_hi, size_lo, data...] +void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) +{ + uint16_t arrCount = openplc_debug_elem_count(arr); + if (arrCount == 0 || startidx >= arrCount || + endidx >= arrCount || startidx > endidx) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_GET; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + uint16_t lastElemIdx = startidx; + uint16_t responseSize = 0; + uint8_t *responsePtr = &(mb_frame[11]); + + for (uint16_t elem = startidx; elem <= endidx; elem++) + { + uint16_t varSize = openplc_debug_size(arr, elem); + // Bounds check — stop packing if this one won't fit. + if ((11 + responseSize + varSize) > MAX_MB_FRAME) break; + if (varSize == 0) { + // Entry has no readable bytes (string stub / out-of-bounds) + // — skip gracefully to keep the scan progressing. + lastElemIdx = elem; + continue; + } + uint16_t n = openplc_debug_read(arr, elem, responsePtr); + if (n == 0) { + lastElemIdx = elem; + continue; + } + responsePtr += n; + responseSize += n; + lastElemIdx = elem; + } + + mb_frame_len = 11 + responseSize; + mb_frame[1] = MB_FC_DEBUG_GET; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = (uint8_t)(lastElemIdx >> 8); + mb_frame[4] = (uint8_t)(lastElemIdx & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[8] = (uint8_t)(scan_counter & 0xFF); + mb_frame[9] = (uint8_t)(responseSize >> 8); + mb_frame[10] = (uint8_t)(responseSize & 0xFF); +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_GET_LIST function code. + * + * This function constructs a Modbus response frame for the DEBUG_GET_LIST function code. + * The response frame includes the trace data for variables specified in the provided index list. + * + * Modbus Response Frame (DEBUG_GET_LIST): + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | + * | FC | Code | Index | Index | | | | | Size | Size | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | + * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * + * @param numIndexes The number of indexes requested. + * @param indexArray Pointer to the array containing variable indexes. + * + * @return void + */ +// Phase 4 PDU: [FC, count_hi, count_lo, (arr:u8, elem_hi, elem_lo)×count] +// Response: [FC, STATUS, last_idx_hi, last_idx_lo, +// tick_hi, tick_mh, tick_ml, tick_lo, +// size_hi, size_lo, data...] +// last_idx is the index *into the request list* that was last successfully +// included — the editor uses it to retry from the next item on overflow. +void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) +{ + uint16_t response_idx = 11; + uint16_t responseSize = 0; + uint16_t lastReqIdx = 0; + + #ifdef MB_SERIAL_ACTIVE + #define VARIDX_SIZE 20 + #else + #define VARIDX_SIZE 60 + #endif + + if (numIndexes > VARIDX_SIZE) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_GET_LIST; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_MEMORY; + return; + } + + // The request indexArray (at mb_frame[4..]) and the response buffer + // (mb_frame[11..]) overlap. Once handle_read writes the first response + // byte, later index entries inside mb_frame are clobbered. Snapshot the + // request first. + uint8_t localIndex[VARIDX_SIZE * 3]; + for (uint16_t i = 0; i < numIndexes * 3; i++) { + localIndex[i] = indexArray[i]; + } + + // Each address pair is 3 bytes: [arr:u8, elem_hi, elem_lo] + for (uint16_t i = 0; i < numIndexes; i++) + { + uint8_t arr = localIndex[i * 3]; + uint16_t elem = (uint16_t)localIndex[i * 3 + 1] << 8 | + (uint16_t)localIndex[i * 3 + 2]; + + uint16_t varSize = openplc_debug_size(arr, elem); + if (varSize == 0) + { + // Out-of-bounds or string stub — skip gracefully. + lastReqIdx = i; + continue; + } + if ((response_idx + varSize) > MAX_MB_FRAME) break; + + uint16_t n = openplc_debug_read(arr, elem, &mb_frame[response_idx]); + if (n == 0) + { + lastReqIdx = i; + continue; + } + response_idx += n; + responseSize += n; + lastReqIdx = i; + } + + mb_frame_len = response_idx; + mb_frame[1] = MB_FC_DEBUG_GET_LIST; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = (uint8_t)(lastReqIdx >> 8); + mb_frame[4] = (uint8_t)(lastReqIdx & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[8] = (uint8_t)(scan_counter & 0xFF); + mb_frame[9] = (uint8_t)(responseSize >> 8); + mb_frame[10] = (uint8_t)(responseSize & 0xFF); +} + +// PDU request: [FC, endian_check_hi, endian_check_lo] +// PDU response: [FC, STATUS, md5_ascii..., endian_marker_hi, endian_marker_lo] +// +// The target always writes variable data in native byte order — STruC++ does +// no server-side byte-order adaptation, force/read is pure memcpy. To let +// the editor detect what "native" means here, the MD5 response trailer +// writes the literal value 0xDEAD via a native `uint16_t*` store. The +// bytes that land in the response are therefore in the target's native +// byte order: +// +// LE target → trailer bytes = [0xAD, 0xDE] +// BE target → trailer bytes = [0xDE, 0xAD] +// +// The editor inspects those two bytes after MD5 verification and decides +// whether subsequent force/read traffic needs byte-swapping at its end. +// +// The probe bytes the editor sends are intentionally ignored — the trailer +// is a runtime-driven sentinel, not an echo. The argument stays in the +// signature for ABI compatibility with the dispatcher. +void debugGetMd5(void * /*endianness*/) +{ + mb_frame[1] = MB_FC_DEBUG_GET_MD5; + mb_frame[2] = MB_DEBUG_SUCCESS; + + const char md5[] = PROGRAM_MD5; + int md5_len = 0; + for (md5_len = 0; md5[md5_len] != '\0'; md5_len++) + { + mb_frame[md5_len + 3] = md5[md5_len]; + } + + // Native-order store of the endianness sentinel. Written byte-wise + // (not via `*reinterpret_cast`) because `md5_len + 3` is an + // odd offset for a 32-char MD5, and a typed 16-bit store there is an + // unaligned access that HardFaults on Cortex-M0+ (SAMD21: MKR Zero / + // P1AM-100) — hanging the device on the first debugger request. Copying + // the two bytes of a native-order uint16_t preserves the target's byte + // ordering (the signal the editor uses to choose its swap behaviour) + // while keeping every access byte-aligned. + const uint16_t endian_sentinel = 0xDEAD; + const uint8_t *sentinel_bytes = reinterpret_cast(&endian_sentinel); + mb_frame[md5_len + 3] = sentinel_bytes[0]; + mb_frame[md5_len + 4] = sentinel_bytes[1]; + mb_frame_len = md5_len + 5; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, running:u8, tick:u32 BE, uptime_ms:u32 BE] +// +// Lightweight liveness/diagnostic probe that does not require a full debug +// session. `running` is always 1 on baremetal (the PLC scan is unconditional); +// `tick` is the scan counter (same value the read FCs report), so a client can +// tell whether the PLC is actually cycling by watching it advance. `uptime_ms` +// is millis() since boot. +void debugGetStatus() +{ + uint32_t uptime = (uint32_t)millis(); + + mb_frame[1] = MB_FC_DEBUG_GET_STATUS; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = 1; // PLC scan is always running on baremetal + mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[7] = (uint8_t)(scan_counter & 0xFF); + mb_frame[8] = (uint8_t)((uptime >> 24) & 0xFF); + mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); + mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); + mb_frame[11] = (uint8_t)(uptime & 0xFF); + mb_frame_len = 12; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, version_ascii...] (no NUL terminator) +// +// Reports OPENPLC_RUNTIME_VERSION (defined in openplc_version.h). The editor +// reads the ASCII bytes up to the end of the frame. +void debugGetVersion() +{ + mb_frame[1] = MB_FC_DEBUG_GET_VERSION; + mb_frame[2] = MB_DEBUG_SUCCESS; + + const char ver[] = OPENPLC_RUNTIME_VERSION; + uint16_t i = 0; + for (i = 0; ver[i] != '\0'; i++) + { + if ((uint16_t)(3 + i) >= MAX_MB_FRAME) break; // never overrun the frame + mb_frame[3 + i] = (uint8_t)ver[i]; + } + mb_frame_len = 3 + i; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, id_len:u8, id_bytes...] +// +// Returns the unique hardware ID via ArduinoUniqueID. id_len is UniqueIDsize +// (architecture-dependent: AVR 9-10, ESP8266 4, ESP32 6, SAM/SAMD 16, STM32 +// 12, Teensy 8). On a core without support, id_len = 0 and no bytes follow. +void debugGetBoardId() +{ + mb_frame[1] = MB_FC_DEBUG_GET_BOARD_ID; + mb_frame[2] = MB_DEBUG_SUCCESS; + +#ifdef OPENPLC_HAS_UNIQUE_ID + uint8_t idLen = (uint8_t)UniqueIDsize; + // Clamp so [FC][STATUS][id_len][id_bytes...] always fits the frame. + if ((uint16_t)(4 + idLen) > MAX_MB_FRAME) idLen = (uint8_t)(MAX_MB_FRAME - 4); + mb_frame[3] = idLen; + for (uint8_t i = 0; i < idLen; i++) + mb_frame[4 + i] = UniqueID[i]; + mb_frame_len = 4 + idLen; +#else + mb_frame[3] = 0; // no unique-id support on this core + mb_frame_len = 4; +#endif +} diff --git a/resources/sources/Baremetal/modbus_debug.h b/resources/sources/Baremetal/modbus_debug.h new file mode 100644 index 000000000..1c03df15d --- /dev/null +++ b/resources/sources/Baremetal/modbus_debug.h @@ -0,0 +1,29 @@ +/* +modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x48) +Copyright (C) 2022 OpenPLC - Thiago Alves + +The debugger PDU handlers, dispatched from process_mbpacket. Kept ungated: the +dispatch in modbus_pdu references them unconditionally (the always-on debugger is +present on every baremetal build). This is the growth home for future custom FCs +(e.g. the 0x49-0x4C licensing set). +*/ + +#ifndef MODBUS_DEBUG_H +#define MODBUS_DEBUG_H + +#include "modbus_frame.h" + +// Phase 4 debugger entrypoints. Signatures changed from MatIEC-era +// (flat u16 index) to the (array_idx: u8, elem_idx: u16) addressing model. +void debugInfo(void); +void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, + uint16_t len, void *value); +void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx); +void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray); +void debugGetMd5(void *endianness); +// Always-on debugger extras — served even without full Modbus (DEBUGGER_ENABLED). +void debugGetStatus(void); +void debugGetVersion(void); +void debugGetBoardId(void); + +#endif diff --git a/resources/sources/Baremetal/modbus_frame.cpp b/resources/sources/Baremetal/modbus_frame.cpp new file mode 100644 index 000000000..ae4055108 --- /dev/null +++ b/resources/sources/Baremetal/modbus_frame.cpp @@ -0,0 +1,21 @@ +/* +modbus_frame.cpp - Shared Modbus message seam for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_frame.h" + +//Global Modbus vars — the shared frame buffer and the slave/register struct. +struct MBinfo modbus; +uint8_t mb_frame[MAX_MB_FRAME]; +uint16_t mb_frame_len; + +void exceptionResponse(uint16_t fcode, uint16_t excode) +{ + //Clean frame buffer (leave only SlaveID) + mb_frame_len = 3; + for (int i = 0; i < mb_frame_len; i++) mb_frame[i] = 0; + mb_frame[0] = modbus.slaveid; + mb_frame[1] = fcode + 0x80; + mb_frame[2] = excode; +} diff --git a/resources/sources/Baremetal/modbus_frame.h b/resources/sources/Baremetal/modbus_frame.h new file mode 100644 index 000000000..f751eabd8 --- /dev/null +++ b/resources/sources/Baremetal/modbus_frame.h @@ -0,0 +1,23 @@ +/* +modbus_frame.h - Shared Modbus message seam for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#ifndef MODBUS_FRAME_H +#define MODBUS_FRAME_H + +#include "modbus_types.h" + +// The one buffer every layer shares. A transport fills mb_frame[0..mb_frame_len), +// calls process_mbpacket() (which builds the response back into mb_frame), then +// writes it out. `modbus` carries the slave id — used in EVERY build, including +// debug-only — plus the operation register banks, which are allocated only when +// full Modbus is enabled (see modbus_registers.cpp under MODBUS_ENABLED). +extern struct MBinfo modbus; +extern uint8_t mb_frame[MAX_MB_FRAME]; +extern uint16_t mb_frame_len; + +// Build a Modbus exception response into mb_frame: [slaveid][fcode|0x80][excode]. +void exceptionResponse(uint16_t fcode, uint16_t excode); + +#endif diff --git a/resources/sources/Baremetal/modbus_pdu.cpp b/resources/sources/Baremetal/modbus_pdu.cpp new file mode 100644 index 000000000..ba1c963d2 --- /dev/null +++ b/resources/sources/Baremetal/modbus_pdu.cpp @@ -0,0 +1,182 @@ +/* +modbus_pdu.cpp - Transport-agnostic Modbus PDU dispatch + per-FC frame shape +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_pdu.h" +#include "modbus_registers.h" +#include "modbus_debug.h" + +// Derived per function code, exactly as process_mbpacket() below parses the +// fields — the single source of truth for the RTU frame shape. +int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n) +{ + if (n < 2) return 0; // need at least id + FC + switch (f[1]) + { + case MB_FC_READ_COILS: + case MB_FC_READ_INPUT_STAT: + case MB_FC_READ_REGS: + case MB_FC_READ_INPUT_REGS: + case MB_FC_WRITE_COIL: + case MB_FC_WRITE_REG: + return 8; // [id][fc][a:2][b:2][crc:2] + case MB_FC_WRITE_COILS: + case MB_FC_WRITE_REGS: + if (n < 7) return 0; // byte count lives at f[6] + return 9 + (int32_t)f[6]; // + [bc:1][data:bc][crc:2] + case MB_FC_DEBUG_INFO: + return 4; // [id][fc][crc:2] + case MB_FC_DEBUG_GET: + return 9; // [id][fc][arr:1][s:2][e:2][crc:2] + case MB_FC_DEBUG_GET_LIST: + if (n < 4) return 0; // count lives at f[2..3] + return 6 + 3 * (int32_t)(((uint16_t)f[2] << 8) | f[3]); + case MB_FC_DEBUG_SET: + if (n < 8) return 0; // value len lives at f[6..7] + return 10 + (int32_t)(((uint16_t)f[6] << 8) | f[7]); + case MB_FC_DEBUG_GET_MD5: + return 8; // [id][fc][endian:2][00:2][crc:2] + case MB_FC_DEBUG_GET_STATUS: + case MB_FC_DEBUG_GET_VERSION: + case MB_FC_DEBUG_GET_BOARD_ID: + return 4; // [id][fc][crc:2] + default: + return -1; // not one of our function codes + } +} + +// The debug FCs are private, well-formed and performance-sensitive, so their RTU +// frames skip CRC. Keep this list in lockstep with the DEBUG cases below and in +// mb_pdu_request_len above. +bool mb_pdu_skips_crc(uint8_t fc) +{ + switch (fc) + { + case MB_FC_DEBUG_INFO: + case MB_FC_DEBUG_SET: + case MB_FC_DEBUG_GET: + case MB_FC_DEBUG_GET_LIST: + case MB_FC_DEBUG_GET_MD5: + case MB_FC_DEBUG_GET_STATUS: + case MB_FC_DEBUG_GET_VERSION: + case MB_FC_DEBUG_GET_BOARD_ID: + return true; + default: + return false; + } +} + +void process_mbpacket() +{ + uint8_t fcode = mb_frame[1]; +#ifdef MODBUS_ENABLED + // Standard Modbus fields — only used by the operation FCs, which are + // compiled out in debug-only builds (so guard to avoid unused-var warnings). + uint16_t field1 = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; + uint16_t field2 = (uint16_t)mb_frame[4] << 8 | (uint16_t)mb_frame[5]; +#endif + void *endianness_check = &mb_frame[2]; + + switch (fcode) + { +#ifdef MODBUS_ENABLED + // Standard Modbus operation FCs read/write the coil/register buffers, + // which only exist when full Modbus is enabled. In debug-only builds + // these cases are compiled out, so operation requests fall through to + // the default and get an ILLEGAL_FUNCTION exception. + case MB_FC_WRITE_REG: + //field1 = reg, field2 = value + writeSingleRegister(field1, field2); + break; + + case MB_FC_READ_REGS: + //field1 = startreg, field2 = numregs + readRegisters(field1, field2); + break; + + case MB_FC_WRITE_REGS: + //field1 = startreg, field2 = status + writeMultipleRegisters(field1, field2, mb_frame[6]); + break; + + case MB_FC_READ_COILS: + //field1 = startreg, field2 = numregs + readCoils(field1, field2); + break; + + case MB_FC_READ_INPUT_STAT: + //field1 = startreg, field2 = numregs + readInputStatus(field1, field2); + break; + + case MB_FC_READ_INPUT_REGS: + //field1 = startreg, field2 = numregs + readInputRegisters(field1, field2); + break; + + case MB_FC_WRITE_COIL: + //field1 = reg, field2 = status + writeSingleCoil(field1, field2); + break; + + case MB_FC_WRITE_COILS: + //field1 = startreg, field2 = numoutputs + writeMultipleCoils(field1, field2, mb_frame[6]); + break; +#endif // MODBUS_ENABLED + + case MB_FC_DEBUG_INFO: + debugInfo(); + break; + + case MB_FC_DEBUG_GET: + { + // PDU: [FC:1][arr:u8][start_elem:u16][end_elem:u16] + uint8_t arr = mb_frame[2]; + uint16_t startIdx = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; + uint16_t endIdx = (uint16_t)mb_frame[5] << 8 | (uint16_t)mb_frame[6]; + debugGetTrace(arr, startIdx, endIdx); + } + break; + + case MB_FC_DEBUG_GET_LIST: + { + // PDU: [FC:1][count:u16][(arr:u8, elem:u16)×count] + uint16_t numIndexes = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; + debugGetTraceList(numIndexes, &mb_frame[4]); + } + break; + + case MB_FC_DEBUG_SET: + { + // PDU: [FC:1][arr:u8][elem:u16][force:u8][len:u16][value...] + uint8_t arr = mb_frame[2]; + uint16_t elem = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; + uint8_t flag = mb_frame[5]; + uint16_t len = (uint16_t)mb_frame[6] << 8 | (uint16_t)mb_frame[7]; + void *value = &mb_frame[8]; + debugSetTrace(arr, elem, flag, len, value); + } + break; + + case MB_FC_DEBUG_GET_MD5: + debugGetMd5(endianness_check); + break; + + case MB_FC_DEBUG_GET_STATUS: + debugGetStatus(); + break; + + case MB_FC_DEBUG_GET_VERSION: + debugGetVersion(); + break; + + case MB_FC_DEBUG_GET_BOARD_ID: + debugGetBoardId(); + break; + + default: + exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); + } +} diff --git a/resources/sources/Baremetal/modbus_pdu.h b/resources/sources/Baremetal/modbus_pdu.h new file mode 100644 index 000000000..e0ff077bf --- /dev/null +++ b/resources/sources/Baremetal/modbus_pdu.h @@ -0,0 +1,34 @@ +/* +modbus_pdu.h - Transport-agnostic Modbus PDU dispatch + per-FC frame shape +Copyright (C) 2022 OpenPLC - Thiago Alves + +The protocol layer: it owns the set of function codes and their shapes. A +transport fills mb_frame with a request, calls process_mbpacket() to dispatch it +(operation FC -> modbus_registers, debug FC -> modbus_debug) and read the +response back out. The transport does NOT know the FC set — it asks this layer +via mb_pdu_request_len() / mb_pdu_skips_crc(), so adding a function code touches +only this file plus its handler, never the transports. +*/ + +#ifndef MODBUS_PDU_H +#define MODBUS_PDU_H + +#include "modbus_frame.h" + +// Dispatch the PDU in mb_frame[0..mb_frame_len) to its handler and build the +// response back into mb_frame. +void process_mbpacket(); + +// Total on-wire length (slave id + PDU + 2 CRC bytes) of the RTU request whose +// first `n` bytes are in `f`: >0 for a known length, 0 when more header bytes are +// needed to size it, -1 for a function code we do not serve (so the byte cannot +// be a frame head). Length is implicit in Modbus RTU — derived per FC, exactly as +// process_mbpacket() later parses the fields. +int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n); + +// True for the private debugger FCs, whose RTU frames deliberately skip CRC +// validation (they are well-formed and performance-sensitive). Lets the serial +// transport decide CRC handling without hardcoding the debug FC list. +bool mb_pdu_skips_crc(uint8_t fc); + +#endif diff --git a/resources/sources/Baremetal/modbus_registers.cpp b/resources/sources/Baremetal/modbus_registers.cpp new file mode 100644 index 000000000..a1be65c24 --- /dev/null +++ b/resources/sources/Baremetal/modbus_registers.cpp @@ -0,0 +1,525 @@ +/* +modbus_registers.cpp - Modbus register store + operation function codes +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_registers.h" + +// The register banks and operation FCs only exist when full Modbus is enabled. +// In a debug-only build this whole TU compiles to nothing, saving flash/SRAM. +#ifdef MODBUS_ENABLED + +bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus) +{ + //Save sizes + modbus.holding_size = size_holding; + modbus.dint_memory_size = size_dint_memory; + modbus.lint_memory_size = size_lint_memory; + modbus.coils_size = size_coils; + modbus.input_regs_size = size_inputregs; + modbus.input_status_size = size_inputstatus; + + //round discrete regs sizes + if (size_coils % 8 > 0) + size_coils = (size_coils / 8) + 1; + else + size_coils = size_coils / 8; + if (size_inputstatus % 8 > 0) + size_inputstatus = (size_inputstatus / 8) + 1; + else + size_inputstatus = (size_inputstatus / 8); + + modbus.coils = (uint8_t *)malloc(size_coils * sizeof(uint8_t)); + if (modbus.coils == NULL) return false; + memset(modbus.coils, 0, size_coils * sizeof(uint8_t)); + + modbus.holding = (uint16_t *)malloc(size_holding * sizeof(uint16_t)); + if (modbus.holding == NULL) return false; + memset(modbus.holding, 0, size_holding * sizeof(uint16_t)); + + if (size_dint_memory > 0) + { + modbus.dint_memory = (uint32_t *)malloc(size_dint_memory * sizeof(uint32_t)); + if (modbus.dint_memory == NULL) return false; + memset(modbus.dint_memory, 0, size_dint_memory * sizeof(uint32_t)); + } + + if (size_lint_memory > 0) + { + modbus.lint_memory = (uint64_t *)malloc(size_lint_memory * sizeof(uint64_t)); + if (modbus.lint_memory == NULL) return false; + memset(modbus.lint_memory, 0, size_lint_memory * sizeof(uint64_t)); + } + + modbus.input_status = (uint8_t *)malloc(size_inputstatus * sizeof(uint8_t)); + if (modbus.input_status == NULL) return false; + memset(modbus.input_status, 0, size_inputstatus * sizeof(uint8_t)); + + modbus.input_regs = (uint16_t *)malloc(size_inputregs * sizeof(uint16_t)); + if (modbus.input_regs == NULL) return false; + memset(modbus.input_regs, 0, size_inputregs * sizeof(uint16_t)); + + return true; +} + +bool get_discrete(uint16_t addr, bool regtype) +{ + uint8_t byte_addr = addr / 8; + uint8_t bit_addr = addr % 8; + if (regtype == COILS) + return bitRead(modbus.coils[byte_addr], bit_addr); + else + return bitRead(modbus.input_status[byte_addr], bit_addr); +} + +void write_discrete(uint16_t addr, bool regtype, bool value) +{ + uint8_t byte_addr = addr / 8; + uint8_t bit_addr = addr % 8; + if (regtype == COILS) + bitWrite(modbus.coils[byte_addr], bit_addr, value); + else + bitWrite(modbus.input_status[byte_addr], bit_addr, value); +} + +//Modbus handling functions +void readRegisters(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x007D) + { + exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg+numregs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //calculate the query reply message length + mb_frame_len = 3 + (numregs * 2); + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_REGS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_REGS; + mb_frame[2] = mb_frame_len - 3; //byte count + + uint16_t val; + uint16_t i = 0; + uint8_t pos = 0; + while(numregs--) + { + if ((startreg + i) < modbus.holding_size) + { + //retrieve the value from the register bank for the current register + val = modbus.holding[startreg + i]; + } + else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if ((startreg + i) % 2 == 0) //first word + { + pos = ((startreg + i) - modbus.holding_size) / 2; + val = (uint16_t)(modbus.dint_memory[pos] >> 16); + } + else //second word + { + pos = ((startreg + i) - modbus.holding_size - 1) / 2; + val = (uint16_t)(modbus.dint_memory[pos] & 0xffff); + } + } + else //64-bit registers + { + if ((startreg + i) % 4 == 0) //first word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + val = (uint16_t)(modbus.lint_memory[pos] >> 48); + } + else if ((startreg + i) % 4 == 1) //second word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + val = (uint16_t)((modbus.lint_memory[pos] >> 32) & 0xffff); + } + else if ((startreg + i) % 4 == 2) //third word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + val = (uint16_t)((modbus.lint_memory[pos] >> 16) & 0xffff); + } + else //fourth word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + val = (uint16_t)(modbus.lint_memory[pos] & 0xffff); + } + } + + //write the high byte of the register value + mb_frame[3 + (i * 2)] = val >> 8; + //write the low byte of the register value + mb_frame[4 + (i * 2)] = val & 0xFF; + i++; + } +} + +void writeSingleRegister(uint16_t reg, uint16_t value) +{ + if (reg >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_WRITE_REG, MB_EX_ILLEGAL_ADDRESS); + return; + } + + uint8_t pos = 0; + + if (reg < modbus.holding_size) + { + modbus.holding[reg] = value; + } + else if (reg < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if (reg % 2 == 0) //first word + { + pos = (reg - modbus.holding_size) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word + modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word + } + else //second word + { + pos = (reg - modbus.holding_size - 1) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; + modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; + } + + } + else //64-bit registers + { + if (reg % 4 == 0) //first word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word + } + else if (reg % 4 == 1) //second word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); + } + else if (reg % 4 == 2) //third word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); + } + else //fourth word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; + } + } +} + +void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount) +{ + //Check value + if (numoutputs < 0x0001 || numoutputs > 0x007B || bytecount != 2 * numoutputs) + { + exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address (startreg...startreg + numregs) + if ((startreg + numoutputs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Prepare answer frame buffer + mb_frame_len = 6; + mb_frame[1] = MB_FC_WRITE_REGS; + mb_frame[2] = startreg >> 8; + mb_frame[3] = startreg & 0x00FF; + mb_frame[4] = numoutputs >> 8; + mb_frame[5] = numoutputs & 0x00FF; + + uint16_t value; + uint16_t i = 0; + uint8_t pos = 0; + while(numoutputs--) + { + value = (uint16_t)mb_frame[7+i*2] << 8 | (uint16_t)mb_frame[8+i*2]; + + if ((startreg + i) < modbus.holding_size) + { + modbus.holding[(startreg + i)] = value; + } + else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if ((startreg + i) % 2 == 0) //first word + { + pos = ((startreg + i) - modbus.holding_size) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word + modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word + } + else //second word + { + pos = ((startreg + i) - modbus.holding_size - 1) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; + modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; + } + + } + else //64-bit registers + { + if ((startreg + i) % 4 == 0) //first word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word + } + else if ((startreg + i) % 4 == 1) //second word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); + } + else if ((startreg + i) % 4 == 2) //third word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); + } + else //fourth word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; + } + } + + i++; + } +} + +void readCoils(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x07D0) + { + exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if (startreg + numregs > modbus.coils_size) + { + exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Determine the message length = slaveid + function type + byte count and + //for each group of 8 registers the message length increases by 1 + mb_frame_len = 3 + numregs/8; + if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_COILS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_COILS; + mb_frame[2] = mb_frame_len - 3; //byte count (mb_frame_len - slave id, function code and byte count) + + uint8_t bitn = 0; + uint16_t totregs = numregs; + uint16_t i; + while (numregs) + { + i = (totregs - numregs--) / 8; + if (get_discrete((uint8_t)startreg, COILS)) + bitSet(mb_frame[3+i], bitn); + else + bitClear(mb_frame[3+i], bitn); + + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +void readInputStatus(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x07D0) + { + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg + numregs) > modbus.input_status_size) + { + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Determine the message length = function type, byte count and + //for each group of 8 registers the message length increases by 1 + mb_frame_len = 3 + numregs/8; + if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_INPUT_STAT; + mb_frame[2] = mb_frame_len - 3; + + byte bitn = 0; + uint16_t totregs = numregs; + uint16_t i; + while (numregs) + { + i = (totregs - numregs--) / 8; + if (get_discrete(startreg, INPUTSTATUS)) + bitSet(mb_frame[3+i], bitn); + else + bitClear(mb_frame[3+i], bitn); + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +void readInputRegisters(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x007D) + { + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg + numregs) > modbus.input_regs_size) + { + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //calculate the query reply message length + //for each register queried add 2 bytes + mb_frame_len = 3 + (numregs * 2); + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_INPUT_REGS; + mb_frame[2] = mb_frame_len - 3; + + uint16_t val; + uint16_t i = 0; + while(numregs--) + { + //retrieve the value from the register bank for the current register + val = modbus.input_regs[startreg + i]; + //write the high byte of the register value + mb_frame[3 + (i * 2)] = val >> 8; + //write the low byte of the register value + mb_frame[4 + (i * 2)] = val & 0xFF; + i++; + } +} + +void writeSingleCoil(uint16_t reg, uint16_t status) +{ + //Check value (status) + if (status != 0xFF00 && status != 0x0000) + { + exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if (reg > (modbus.coils_size - 1)) + { + exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Execute + write_discrete(reg, COILS, status == 0xFF00 ? true : false); +} + +void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount) +{ + //Check value + uint8_t bytecount_calc = numoutputs / 8; + if (numoutputs%8) bytecount_calc++; + if (numoutputs < 0x0001 || numoutputs > 0x07B0 || bytecount != bytecount_calc) + { + exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address (startreg...startreg + numregs) + if ((startreg + numoutputs) > modbus.coils_size) + { + exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Prepare answer frame buffer + mb_frame_len = 6; + mb_frame[1] = MB_FC_WRITE_COILS; + mb_frame[2] = startreg >> 8; + mb_frame[3] = startreg & 0x00FF; + mb_frame[4] = numoutputs >> 8; + mb_frame[5] = numoutputs & 0x00FF; + + //Execute + uint8_t bitn = 0; + uint16_t totoutputs = numoutputs; + uint16_t i; + while (numoutputs) + { + i = (totoutputs - numoutputs--) / 8; + write_discrete(startreg, COILS, bitRead(mb_frame[7+i], bitn)); + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +#endif // MODBUS_ENABLED diff --git a/resources/sources/Baremetal/modbus_registers.h b/resources/sources/Baremetal/modbus_registers.h new file mode 100644 index 000000000..27258273b --- /dev/null +++ b/resources/sources/Baremetal/modbus_registers.h @@ -0,0 +1,31 @@ +/* +modbus_registers.h - Modbus register store + operation function codes +Copyright (C) 2022 OpenPLC - Thiago Alves + +The coil/holding/input register banks and the standard Modbus operation FCs +(0x01-0x10). Compiled only under MODBUS_ENABLED — a debug-only build never +references these symbols (the debugger reads IEC variables directly through the +strucpp debug table, needing no operation buffers). The `modbus` instance itself +lives in modbus_frame.* because its slave id is shared by every build. +*/ + +#ifndef MODBUS_REGISTERS_H +#define MODBUS_REGISTERS_H + +#include "modbus_frame.h" + +bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus); +bool get_discrete(uint16_t addr, bool regtype); +void write_discrete(uint16_t addr, bool regtype, bool value); + +//Modbus operation function-code handlers +void readRegisters(uint16_t startreg, uint16_t numregs); +void writeSingleRegister(uint16_t reg, uint16_t value); +void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount); +void readCoils(uint16_t startreg, uint16_t numregs); +void readInputStatus(uint16_t startreg, uint16_t numregs); +void readInputRegisters(uint16_t startreg, uint16_t numregs); +void writeSingleCoil(uint16_t reg, uint16_t status); +void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount); + +#endif diff --git a/resources/sources/Baremetal/modbus_serial.cpp b/resources/sources/Baremetal/modbus_serial.cpp new file mode 100644 index 000000000..c68f36a5c --- /dev/null +++ b/resources/sources/Baremetal/modbus_serial.cpp @@ -0,0 +1,269 @@ +/* +modbus_serial.cpp - Modbus RTU / debugger serial transport +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_serial.h" +#include "modbus_pdu.h" // process_mbpacket, mb_pdu_request_len, mb_pdu_skips_crc +#include "modbus_crc.h" // calcCrc + +#if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) +#include "Controllino.h" +#endif + +//Serial timing/port state. +Stream* mb_serialport; +int8_t mb_txpin; +uint16_t mb_t15; // inter character time out +uint16_t mb_t35; // frame delay + +void mbconfig_serial_iface(Stream* port, long baud, int txPin) +{ + mb_serialport = port; + mb_txpin = txPin; + //(*port).begin(baud); //Initialization already happened on main .ino file + + //RS-485 control + if (txPin >= 0) + { + pinMode(txPin, OUTPUT); + digitalWrite(txPin, LOW); + } + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (mb_serialport == &Serial3) + Controllino_RS485Init(); + #elif defined(CONTROLLINO_MICRO) + if (mb_serialport == &Serial2) { + pinMode(CUSTOM_RS485_DEFAULT_DE_PIN, OUTPUT); + pinMode(CUSTOM_RS485_DEFAULT_RE_PIN, OUTPUT); + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); + } + #endif + + // Modbus states that a baud rate higher than 19200 must use a fixed 750 us + // for inter character time out. For baud rates below 19200 the timing + // is more critical and has to be calculated. + // E.g. 9600 baud in a 11 bit packet is 9600/11 = 872 characters per second + // In milliseconds this will be 872 characters per 1000ms. So for 1 character + // 1000ms/872 characters is 1.14583ms per character. Finally modbus states + // an inter-character must be 1.5T or 1.5 times longer than a character. Thus + // 1.5T = 1.14583ms * 1.5 = 1.71875ms. + // Thus the formula is T1.5(us) = (1000ms * 1000(us) * 1.5 * 11bits)/baud + // 1000ms * 1000(us) * 1.5 * 11bits = 16500000 can be calculated as a constant + + if (baud > 19200) + mb_t15 = 750; + else + mb_t15 = 16500000/baud; // 1T * 1.5 = T1.5 + + /* The modbus definition of a frame delay is a waiting period of 3.5 character times + between packets.*/ + + mb_t35 = mb_t15 * 3.5; +} + +#ifdef MB_SERIAL_ACTIVE +// Inter-frame idle, in milliseconds, used ONLY to abandon a frame whose +// remainder never arrives. Modbus RTU was defined for RS485, where bytes of a +// frame are ~one character time apart (T1.5/T3.5, tens of microseconds at +// 115200) and the byte cadence delimits frames. That assumption is INVALID on +// USB-CDC (and any store-and-forward link): a single request is split into +// 64-byte USB packets separated by USB-frame-scale gaps far longer than T1.5, +// so cadence framing tears requests apart (the bug that made the P1AM-100 / +// SAMD21 debugger crawl). We therefore frame by the request's DECLARED length +// (derived from the function code) and fall back to this idle only to drop a +// truncated partial. It must exceed any intra-frame USB gap yet stay well below +// a master's request timeout. +#define MB_RTU_FRAME_GAP_MS 8 + +// Persistent RX-assembly state. handle_serial() is called every scan cycle and +// never blocks; a request whose bytes straddle several calls is carried across +// them in mb_frame[0..mb_rx_len). (This shares mb_frame with handle_tcp, which +// is safe because an OpenPLC board is configured for a single Modbus transport; +// the two are not driven mid-frame at the same time.) +static uint16_t mb_rx_len = 0; +static uint32_t mb_rx_last_ms = 0; + +#ifdef MBSERIAL_ON_SECONDARY +// Dual-serial: the debugger keeps the default serial while Modbus RTU runs on a +// distinct UART. Each port needs its OWN RX assembly buffer — a partial frame on +// one port must survive while the other is serviced. `mb_frame` becomes a +// transient process/TX buffer, borrowed for one complete transaction at a time +// (safe: Modbus RTU is half-duplex turn-taking and the ports are polled +// sequentially). These extra buffers are compiled ONLY for boards that use a +// secondary Modbus serial (multi-UART, RAM-rich), so single-UART boards keep the +// original single-buffer footprint. +static uint8_t mb_rx_dbg[MAX_MB_FRAME]; +static uint16_t mb_rx_dbg_len = 0; +static uint32_t mb_rx_dbg_last_ms = 0; +static uint8_t mb_rx_rtu[MAX_MB_FRAME]; +static uint16_t mb_rx_rtu_len = 0; +static uint32_t mb_rx_rtu_last_ms = 0; +#endif + +// Drop the first `k` bytes of the assembly buffer, keeping the remainder. Used +// for one-byte realignment on a bad/foreign frame head — NEVER a blind flush — +// so a genuine frame head sitting further into the buffer always survives and +// is eventually found (guarantees resync convergence; no "discard every frame" +// loop). Slides only run on the error path, so the O(n) cost is irrelevant. +static void mb_rtu_drop_front(uint8_t *buf, uint16_t *plen, uint16_t k) +{ + if (k >= *plen) { *plen = 0; return; } + for (uint16_t i = k; i < *plen; i++) + buf[i - k] = buf[i]; + *plen = (uint16_t)(*plen - k); +} + +// Service ONE serial port. `buf`/`plen`/`plast` are the port's own RX-assembly +// state; `slaveid` is its framing id; `txpin` its RS485 driver-enable pin (-1 +// when none). A complete frame is copied into the shared `mb_frame`, processed, +// and the response written back to `port`. In the single-serial build `buf` IS +// `mb_frame` (in-place, no copy); in the dual-serial build each port owns a +// distinct buffer and `mb_frame` is the transient process/TX scratch. +static void handle_serial_port(Stream *port, int8_t txpin, uint8_t slaveid, + uint8_t *buf, uint16_t *plen, uint32_t *plast) +{ + uint16_t packet_crc; + + // 1) Drain the RX buffer without blocking. One frame's bytes may arrive + // across several calls; the scan cycle is never stalled waiting on them. + while (port->available() > 0) + { + if (*plen >= MAX_MB_FRAME) break; // full — let the parser drain it + buf[(*plen)++] = (uint8_t)port->read(); + *plast = millis(); + } + + // 2) Extract every complete frame in the buffer. Each iteration either + // consumes/realigns by >=1 byte or returns to await more data, so the + // loop always terminates. + for (;;) + { + if (*plen == 0) + return; + + // Header byte-alignment: the first byte must be THIS port's slave id. + // This is the cheap framing check, and it is the ONLY validation applied + // to debugger frames (CRC is deliberately skipped on debug FCs for + // performance — those function codes are private and well-formed). + if (buf[0] != slaveid) + { + mb_rtu_drop_front(buf, plen, 1); // foreign/garbage head — slide + continue; + } + + int32_t expected = mb_pdu_request_len(buf, *plen); + + if (expected < 0 || expected > MAX_MB_FRAME) + { + mb_rtu_drop_front(buf, plen, 1); // illegal FC / impossible length + continue; + } + if (expected == 0 || *plen < (uint16_t)expected) + { + // Header incomplete, or the frame's tail has not arrived yet. Wait + // for it; abandon the partial only if its remainder never comes. + if ((uint32_t)(millis() - *plast) > MB_RTU_FRAME_GAP_MS) + *plen = 0; + return; + } + + // 3) A full candidate frame occupies buf[0 .. expected). Move it into the + // shared process buffer (a no-op self-copy on the single-serial path, + // where buf already IS mb_frame). + if (buf != mb_frame) + { + for (int32_t i = 0; i < expected; i++) mb_frame[i] = buf[i]; + } + + // Standard FCs are validated by CRC (the arbiter that makes resync + // trustworthy); a mismatch means corruption or misalignment, so we + // slide one byte and retry instead of discarding the whole buffer. + if (!mb_pdu_skips_crc(mb_frame[1])) + { + mb_frame_len = (uint16_t)expected; + packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); + if (packet_crc != calcCrc()) + { + mb_rtu_drop_front(buf, plen, 1); + continue; + } + } + + // 4) Accepted. Hand the PDU (CRC stripped) to the shared processor, + // which builds the response back into mb_frame. + mb_frame_len = (uint16_t)expected - 2; + process_mbpacket(); + + //Add CRC + //Check if response message is too big for this device + if (mb_frame_len + 2 > MAX_MB_FRAME) exceptionResponse(mb_frame[1], MB_EX_SLAVE_FAILURE); + mb_frame_len += 2; //increase frame length by two bytes to acomodate CRC + packet_crc = calcCrc(); //calculate CRC of the new packet + mb_frame[mb_frame_len - 2] = (uint8_t)(packet_crc >> 8); + mb_frame[mb_frame_len - 1] = (uint8_t)(packet_crc & 0x00FF); + + if (txpin >= 0) + { + digitalWrite(txpin, HIGH); + delayMicroseconds(mb_t35); + } + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (port == &Serial3) // RS485 serial port + Controllino_RS485TxEnable(); // Enable RS485 chip to transmit + #elif defined(CONTROLLINO_MICRO) + if (port == &Serial2) { + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, HIGH); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); + } + #endif + + port->write(mb_frame, mb_frame_len); + port->flush(); + delayMicroseconds(mb_t35); + + if (txpin >= 0) + digitalWrite(txpin, LOW); + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (port == &Serial3) // RS485 serial port + Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data + #elif defined(CONTROLLINO_MICRO) + if (port == &Serial2) { + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, LOW); + } + #endif + + // 5) The request — and the response built over it — consumed the whole + // assembly buffer. Modbus RTU is turn-taking: the master waits for + // this reply before sending its next request, so no following frame + // can already be buffered. Reset for the next request. A + // non-conformant pipelining master simply retransmits after its + // timeout, and the gap/realignment logic above recovers cleanly. + *plen = 0; + return; + } +} + +// Dispatch to one or two serial ports. Single-serial: the debugger and Modbus +// RTU (if any) share one port, assembled in-place in mb_frame. Dual-serial +// (MBSERIAL_ON_SECONDARY): the debugger keeps the default serial while Modbus +// RTU runs on a distinct UART — each with its own RX buffer. +void handle_serial() +{ +#ifdef MBSERIAL_ON_SECONDARY + handle_serial_port(&DEBUG_IFACE, -1, DEBUG_SLAVE, mb_rx_dbg, &mb_rx_dbg_len, &mb_rx_dbg_last_ms); + #ifdef MBSERIAL_TXPIN + handle_serial_port(&MBSERIAL_IFACE, MBSERIAL_TXPIN, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #else + handle_serial_port(&MBSERIAL_IFACE, -1, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #endif +#else + handle_serial_port(mb_serialport, mb_txpin, modbus.slaveid, mb_frame, &mb_rx_len, &mb_rx_last_ms); +#endif +} +#endif // MB_SERIAL_ACTIVE diff --git a/resources/sources/Baremetal/modbus_serial.h b/resources/sources/Baremetal/modbus_serial.h new file mode 100644 index 000000000..eeca143db --- /dev/null +++ b/resources/sources/Baremetal/modbus_serial.h @@ -0,0 +1,32 @@ +/* +modbus_serial.h - Modbus RTU / debugger serial transport +Copyright (C) 2022 OpenPLC - Thiago Alves + +The serial wire: RTU framing (declared-length, not byte-cadence), RS485 tx-enable +timing, and single- or dual-serial polling. It fills mb_frame with a request, +asks modbus_pdu for the frame shape / CRC policy, calls process_mbpacket() and +writes the response back — it holds NO knowledge of the function-code set. +*/ + +#ifndef MODBUS_SERIAL_H +#define MODBUS_SERIAL_H + +#include "modbus_frame.h" + +// Serial timing/port state, configured once by mbconfig_serial_iface(). +extern Stream* mb_serialport; +extern int8_t mb_txpin; +extern uint16_t mb_t15; // inter character time out +extern uint16_t mb_t35; // frame delay + +// Bind + configure the serial interface (RS485 driver-enable pin, T1.5/T3.5 +// timing derived from baud). Serial.begin() itself happens in the .ino sketch. +void mbconfig_serial_iface(Stream* port, long baud, int txPin); + +#ifdef MB_SERIAL_ACTIVE +// Poll the serial port(s) for a complete RTU/debugger frame and answer it. +// Non-blocking; called every scan cycle. +void handle_serial(); +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_tcp.cpp b/resources/sources/Baremetal/modbus_tcp.cpp new file mode 100644 index 000000000..453267469 --- /dev/null +++ b/resources/sources/Baremetal/modbus_tcp.cpp @@ -0,0 +1,250 @@ +/* +modbus_tcp.cpp - Modbus TCP transport (Ethernet / WiFi / ESP ETH) +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_tcp.h" +#include "modbus_pdu.h" // process_mbpacket + +#ifdef MBTCP_ETHERNET +#ifdef BOARD_ESP32 + WiFiServer mb_server(502); + WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#else + EthernetServer mb_server(502); +#endif + uint8_t mb_mbap[MBAP_SIZE]; +#ifdef BOARD_PORTENTA + EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP_WIFI + WiFiServer mb_server(502); + uint8_t mb_mbap[MBAP_SIZE]; +#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) + WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP +void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet) +{ + #ifdef MBTCP_ETHERNET + #ifdef BOARD_ESP32 + + ETH.begin(); + + if (ip != NULL && subnet != NULL && gateway != NULL) + (ETH.config(ip, gateway, subnet, dns)); + + #else + if (ip == NULL) + Ethernet.begin(mac); + else if (dns == NULL) + Ethernet.begin(mac, IPAddress(ip)); + else if (gateway == NULL) + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns)); + else if (subnet == NULL) + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway)); + else + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); + #endif + +// int num_tries = 0; +// while (!ETH.linkUp()) +// { +// delay(500); +// num_tries++; +// if (num_tries == 20) break; +// } + + #endif + #ifdef MBTCP_WIFI + #if defined(BOARD_ESP8266) || defined(BOARD_ESP32) + if (ip != NULL && gateway != NULL && subnet != NULL && dns != NULL) + { + uint8_t secondaryDNS[] = {8, 8, 8, 8}; + WiFi.config(IPAddress(ip), IPAddress(gateway), IPAddress(subnet), IPAddress(dns), IPAddress(secondaryDNS)); + } + mb_server.setNoDelay(true); + #elif defined(BOARD_PORTENTA) + if (ip != NULL && subnet != NULL && gateway != NULL) + { + WiFi.config(IPAddress(ip), IPAddress(subnet), IPAddress(gateway)); + } + #else + if (ip != NULL) + { + if (dns == NULL) + WiFi.config(IPAddress(ip)); + else if (gateway == NULL) + WiFi.config(IPAddress(ip), IPAddress(dns)); + else if (subnet == NULL) + WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway)); + else + WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); + } + #endif + WiFi.begin(MBTCP_SSID, MBTCP_PWD); + int num_tries = 0; + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + num_tries++; + if (num_tries == 10) break; + } + #endif + + mb_server.begin(); + +} + +void handle_tcp() +{ + #ifdef MBTCP_ETHERNET + #ifdef BOARD_ESP32 + WiFiClient client = mb_server.available(); + #else + EthernetClient client = mb_server.available(); + #endif + #endif + + #if defined(MBTCP_WIFI) && !defined(BOARD_ESP8266) && !defined(BOARD_ESP32) + WiFiClient client = mb_server.available(); + #endif + + //ESP and Portenta boards have a slightly different implementation of the WiFi/Ethernet API - therefore their specific + //code lies below + #if (defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA)) || defined(BOARD_PICOW) && (defined(MBTCP_WIFI) || defined(MBTCP_ETHERNET)) + + + #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || (defined(BOARD_ESP32) && defined(MBTCP_ETHERNET)) + if (client) + #else + if (mb_server.hasClient()) + #endif + { + for (int i = 0; i < MAX_SRV_CLIENTS; i++) + { + if (!mb_serverClients[i]) //equivalent to !serverClients[i].connected() + { + #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || defined(BOARD_ESP32) && defined(MBTCP_ETHERNET) + mb_serverClients[i] = client; + #else + mb_serverClients[i] = mb_server.available(); + #endif + break; + } + } + } + + //search all clients for data + for (int i = 0; i < MAX_SRV_CLIENTS; i++) + { + int j = 0; + + + if (mb_serverClients[i].connected() && mb_serverClients[i].available()) + + { + //Read packet + + + while (mb_serverClients[i].available()) + { + mb_mbap[j] = mb_serverClients[i].read(); + j++; + if (j==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) + } + + mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; + + if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet + if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + + j = 0; + while (mb_serverClients[i].available()) + { + mb_frame[j] = mb_serverClients[i].read(); + j++; + if (j==mb_frame_len) break; + } + + //Safety check - discard packages that lie about their size + if (j != mb_frame_len) return; + + //Process packet and write back + process_mbpacket(); + //Calculate packet length for MBAP header (mb_frame_len + 1) + mb_mbap[4] = (mb_frame_len) >> 8; + mb_mbap[5] = (mb_frame_len) & 0x00FF; + + uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; + + //MBAP + for (j = 0 ; j < MBAP_SIZE ; j++) + sendbuffer[j] = mb_mbap[j]; + + //PDU Frame + for (j = 0 ; j < mb_frame_len ; j++) + sendbuffer[j+MBAP_SIZE] = mb_frame[j]; + + //Write back + mb_serverClients[i].write(sendbuffer, mb_frame_len + MBAP_SIZE); + } + } + + //If this is not an ESP board or Portenta board, then here is the default code + #else + if (client) + { + if (client.connected()) + { + int i = 0; + while (client.available()) + { + mb_mbap[i] = client.read(); + i++; + if (i==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) + } + + mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; + + if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet + if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + + i = 0; + while (client.available()) + { + mb_frame[i] = client.read(); + i++; + if (i==mb_frame_len || i==MAX_MB_FRAME) break; + } + + //Safety check - discard packages that lie about their size + if (i != mb_frame_len) return; + + //Process packet and write back + process_mbpacket(); + //Calculate packet length for MBAP header (mb_frame_len + 1) + mb_mbap[4] = (mb_frame_len) >> 8; + mb_mbap[5] = (mb_frame_len) & 0x00FF; + + uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; + + //MBAP + for (i = 0 ; i < MBAP_SIZE ; i++) + sendbuffer[i] = mb_mbap[i]; + + //PDU Frame + for (i = 0 ; i < mb_frame_len ; i++) + sendbuffer[i+MBAP_SIZE] = mb_frame[i]; + + //Write back + client.write(sendbuffer, mb_frame_len + MBAP_SIZE); + } + } + #endif +} +#endif diff --git a/resources/sources/Baremetal/modbus_tcp.h b/resources/sources/Baremetal/modbus_tcp.h new file mode 100644 index 000000000..dcfec3c38 --- /dev/null +++ b/resources/sources/Baremetal/modbus_tcp.h @@ -0,0 +1,77 @@ +/* +modbus_tcp.h - Modbus TCP transport (Ethernet / WiFi / ESP ETH) +Copyright (C) 2022 OpenPLC - Thiago Alves + +The TCP wire: brings the platform networking stack up, accepts up to +MAX_SRV_CLIENTS connections and services MBAP-framed requests. Like the serial +transport it fills mb_frame, calls process_mbpacket() and writes the response +back — no knowledge of the function-code set. +*/ + +#ifndef MODBUS_TCP_H +#define MODBUS_TCP_H + +#include "modbus_frame.h" + +//Platform specific defines and includes +#ifdef MBTCP_ETHERNET +#include +#ifdef BOARD_ESP32 + // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) + #define ETH_PHY_ADDR 0 // DEFAULT VALUE IS 0 YOU CAN OMIT IT + // Type of the Ethernet PHY (LAN8720 or TLK110) + #define ETH_PHY_TYPE ETH_PHY_LAN8720 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) + #define ETH_PHY_POWER -1 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the I²C clock signal for the Ethernet PHY + #define ETH_PHY_MDC 23 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the I²C IO signal for the Ethernet PHY + #define ETH_PHY_MDIO 18 // DEFAULT VALUE YOU CAN OMIT IT + // External clock from crystal oscillator + #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN // DEFAULT VALUE YOU CAN OMIT IT + #include + #include +#else + #include +#endif +#endif + +#ifdef MBTCP_WIFI +#if defined(BOARD_ESP8266) +#include +#elif defined(BOARD_ESP32) +#include +#elif defined(BOARD_WIFININA) +#include +#else +#include +#include +#endif +#endif + +#ifdef MBTCP_ETHERNET +#ifdef BOARD_ESP32 + extern WiFiServer mb_server; +#else + extern EthernetServer mb_server; +#endif + extern uint8_t mb_mbap[MBAP_SIZE]; +#ifdef BOARD_PORTENTA + extern EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP_WIFI + extern WiFiServer mb_server; + extern uint8_t mb_mbap[MBAP_SIZE]; +#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) + extern WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP +void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet); +void handle_tcp(); +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_types.h b/resources/sources/Baremetal/modbus_types.h new file mode 100644 index 000000000..f75e3af8e --- /dev/null +++ b/resources/sources/Baremetal/modbus_types.h @@ -0,0 +1,90 @@ +/* +modbus_types.h - Shared type/constant declarations for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves + +Pure declarations only (enums, MBinfo, frame-size constants, status codes, +bit helpers). No storage, no functions — every Modbus TU includes this so the +protocol, transport, register and debug layers agree on the same contracts. +*/ + +#ifndef MODBUS_TYPES_H +#define MODBUS_TYPES_H + +// Brings , the generated defines.h and the composite build gates +// (MB_SERIAL_ACTIVE, DEBUG_* defaults). Every modbus_* TU reaches defines.h +// through this single path — defines.h itself has no include guard. +#include "modbus_config.h" + +#ifndef bitRead + #define bitRead(value, bit) (((value) >> (bit)) & 0x01) +#endif +//#define bitSet(value, bit) ((value) |= (1UL << (bit))) +//#define bitClear(value, bit) ((value) &= ~(1UL << (bit))) +#ifndef bitWrite + #define bitWrite(value, bit, bitvalue) (bitvalue ? bitSet(value, bit) : bitClear(value, bit)) +#endif + +#define COILS 0 +#define INPUTSTATUS 1 + +#if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega168__) || defined(__AVR_ATmega32U4__) || defined(__AVR_ATmega16U4__) + #define MAX_MB_FRAME 128 +#else + #define MAX_MB_FRAME 256 +#endif +#define MAX_SRV_CLIENTS 3 //how many clients should be able to connect to TCP server at the same time +#define MBAP_SIZE 6 + +// Status codes (match strucpp::debug::STATUS_* in debug_dispatch.hpp, kept +// as macros here so the Modbus layer doesn't have to include the C++ +// runtime header when the rest of the protocol is C-style). +#define MB_DEBUG_SUCCESS 0x7E +#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 +#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 + +//Modbus registers struct +struct MBinfo { + uint8_t slaveid; + uint16_t *holding; + uint8_t holding_size; + uint32_t *dint_memory; + uint8_t dint_memory_size; + uint64_t *lint_memory; + uint8_t lint_memory_size; + uint8_t *coils; + uint8_t coils_size; + uint16_t *input_regs; + uint8_t input_regs_size; + uint8_t *input_status; + uint8_t input_status_size; +}; + +//Function Codes +enum { + MB_FC_READ_COILS = 0x01, // Read Coils (Output) Status 0xxxx + MB_FC_READ_INPUT_STAT = 0x02, // Read Input Status (Discrete Inputs) 1xxxx + MB_FC_READ_REGS = 0x03, // Read Holding Registers 4xxxx + MB_FC_READ_INPUT_REGS = 0x04, // Read Input Registers 3xxxx + MB_FC_WRITE_COIL = 0x05, // Write Single Coil (Output) 0xxxx + MB_FC_WRITE_REG = 0x06, // Preset Single Register 4xxxx + MB_FC_WRITE_COILS = 0x0F, // Write Multiple Coils (Outputs) 0xxxx + MB_FC_WRITE_REGS = 0x10, // Write block of contiguous registers 4xxxx + MB_FC_DEBUG_INFO = 0x41, // Request debug variables count + MB_FC_DEBUG_SET = 0x42, // Debug set trace (force variable) + MB_FC_DEBUG_GET = 0x43, // Debug get trace (read variables) + MB_FC_DEBUG_GET_LIST = 0x44, // Debug get trace list (read list of variables) + MB_FC_DEBUG_GET_MD5 = 0x45, // Debug get current program MD5 + MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) + MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version + MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID +}; + +//Exception Codes +enum { + MB_EX_ILLEGAL_FUNCTION = 0x01, // Function Code not Supported + MB_EX_ILLEGAL_ADDRESS = 0x02, // Output Address not exists + MB_EX_ILLEGAL_VALUE = 0x03, // Output Value not in Range + MB_EX_SLAVE_FAILURE = 0x04, // Slave Device Fails to process request +}; + +#endif From c582131bb9d029f1165c3ded844adc4eaf81f785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 10 Jul 2026 14:19:27 +0200 Subject: [PATCH 18/79] docs(firmware): add ARCHITECTURE.md for the Modbus slave modules Reference for the modularized ModbusSlave layer: layer diagram, per-module responsibility table (with build gates and dependencies), request lifecycle (RTU/TCP/dual-serial), the two invariants (transports don't know the FC set; mb_frame is the one seam), a "how to add a function code" guide, and the known single-serial + TCP shared-buffer constraint. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018esifhUpuyPmJB29BneUqr --- resources/sources/Baremetal/ARCHITECTURE.md | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 resources/sources/Baremetal/ARCHITECTURE.md diff --git a/resources/sources/Baremetal/ARCHITECTURE.md b/resources/sources/Baremetal/ARCHITECTURE.md new file mode 100644 index 000000000..a9dcdf184 --- /dev/null +++ b/resources/sources/Baremetal/ARCHITECTURE.md @@ -0,0 +1,126 @@ +# Modbus slave — module architecture + +The `ModbusSlave` layer is split into 10 cohesive `modbus_*` translation units, +each owning one concern and its own build gate. Dependencies point **inward +only** (transport → protocol → handlers → data), and everything is glued by a +single shared buffer, `mb_frame`. + +`Baremetal.ino` is unchanged: it `#include "ModbusSlave.h"` (the umbrella) and +calls `mbtask()` once per scan cycle. + +## Layers + +``` + Baremetal.ino + │ (#include "ModbusSlave.h"; calls mbtask()) + ┌─────────▼──────────┐ + │ ModbusSlave.* │ umbrella header + mbtask() facade + └───┬────────────┬───┘ + ┌─────────────▼──┐ ┌──▼──────────────┐ + TRANSPORT │ modbus_serial │ │ modbus_tcp │ own the "wire" + │ (RTU single/dual)│ │ (Eth/WiFi/ETH) │ + └──────┬──────────┘ └────────┬───────┘ + │ fill mb_frame, │ + │ ask for frame shape, │ + └───────────┬──────────────┘ + ┌────────▼─────────┐ + PROTOCOL │ modbus_pdu │ dispatch + per-FC frame shape + └───┬──────────┬───┘ + ┌─────────────▼─┐ ┌──▼──────────────┐ + HANDLERS │ modbus_registers│ │ modbus_debug │ + │ (store + op FCs)│ │ (0x41-0x48 + …) │ + └──────┬─────────┘ └─────────────────┘ + │ + ┌─────────▼───────────────────────────────────────────────┐ + BASE │ modbus_frame (seam) · modbus_crc · modbus_types · modbus_config │ + └─────────────────────────────────────────────────────────┘ +``` + +## Modules + +| Module | Responsibility | Build gate | Depends on | +|--------|----------------|------------|------------| +| **`modbus_config.h`** | Build configuration. Pulls in the generated `defines.h` (which has **no include guard**) and derives the composite gates (`MB_SERIAL_ACTIVE`, `DEBUG_*` defaults). The single guarded path through which `defines.h` reaches every TU. | — | `defines.h` | +| **`modbus_types.h`** | Shared contracts: FC / exception enums, `struct MBinfo`, `MAX_MB_FRAME`, `MBAP_SIZE`, `MB_DEBUG_*` status codes, bit helpers. Pure declarations, no storage. | — | `modbus_config` | +| **`modbus_frame.*`** | The **seam**: the global `mb_frame` / `mb_frame_len` buffer, the `modbus` instance (slave id + register banks) and `exceptionResponse()`. Every transport fills it, every handler writes into it. | — | `types` | +| **`modbus_crc.*`** | Modbus RTU CRC-16 (`calcCrc`) + the two lookup tables, defined **once** in the `.cpp` (they used to live in a header → one flash copy per TU). | — (RTU) | `frame` | +| **`modbus_registers.*`** | Register store + the standard **operation** FCs (`0x01`–`0x10`): `init_mbregs`, `get/write_discrete`, `read*`/`write*`. Compiled out of debug-only builds. | `MODBUS_ENABLED` | `frame` | +| **`modbus_debug.*`** | The always-on **debugger** FCs (`0x41`–`0x48`): info / set / get / md5 / status / version / board-id. Growth home for future custom FCs (e.g. the `0x49+` licensing set). | — | `frame`, `arduino_runtime_glue`, `ArduinoUniqueID` | +| **`modbus_pdu.*`** | The **protocol** layer: `process_mbpacket()` dispatches each FC to its handler, and it owns the **per-FC frame shape** — `mb_pdu_request_len()` (RTU length by FC) and `mb_pdu_skips_crc()` (which FCs bypass CRC). Single source of truth for "the set of function codes". | — | `registers`, `debug` | +| **`modbus_serial.*`** | The **RTU** transport (single- and dual-serial). Declared-length framing (robust over USB-CDC), one-byte resync, RS485 tx-enable timing, per-port RX assembly buffers. | `MB_SERIAL_ACTIVE` | `pdu`, `crc`, `frame` | +| **`modbus_tcp.*`** | The **TCP** transport (Ethernet / WiFi / ESP ETH). Brings the network stack up, accepts up to `MAX_SRV_CLIENTS`, services MBAP-framed requests. | `MBTCP` | `pdu`, `frame` | +| **`ModbusSlave.*`** | **Umbrella** header (re-includes every `modbus_*.h`, so `Baremetal.ino` is untouched) + the `mbtask()` facade that fans out to `handle_tcp()` / `handle_serial()`. | — | all | + +## Build gates + +Which TUs actually compile is driven by `defines.h` (generated per build) and the +composite gates in `modbus_config.h`: + +- `MODBUS_ENABLED` — full Modbus operations. A debug-only build compiles + `modbus_registers.cpp` to an empty TU (the debugger reads IEC variables + directly through the strucpp debug table, needing no operation buffers). +- `MB_SERIAL_ACTIVE` = `MBSERIAL || DEBUGGER_ENABLED` — the serial transport. + Always on for baremetal (the debugger is always on). +- `MBTCP` (+ `MBTCP_ETHERNET` / `MBTCP_WIFI`) — the TCP transport. +- `MBSERIAL_ON_SECONDARY` — dual-serial: Modbus RTU on a distinct UART while the + debugger keeps the default serial (each with its own RX buffer). Otherwise + single-serial (`MBSERIAL_SHARES_DEBUG_SERIAL`), where RTU/debugger share the + default serial and `mb_frame` doubles as the RX-assembly buffer. + +> **Rule:** any gated TU must see `defines.h`. Because `defines.h` has no include +> guard, it reaches a TU through exactly one guarded path: `modbus_config.h` +> (via `modbus_types.h`). Every `modbus_*` header includes that chain. + +## Request lifecycle + +**RTU (single-serial):** +1. `mbtask()` → `handle_serial()` → `handle_serial_port(mb_serialport, …, mb_frame, …)`. +2. Drain available bytes into `mb_frame`; **ask `modbus_pdu`** via + `mb_pdu_request_len()` how many bytes the frame should be (derived per FC). +3. Unless the FC is a debug FC (`mb_pdu_skips_crc()`), validate the CRC with + `modbus_crc::calcCrc()`. +4. `process_mbpacket()` dispatches: operation FC → `modbus_registers`; debug FC → + `modbus_debug`. The response is built back into `mb_frame`. +5. `handle_serial_port` appends the CRC and writes to the serial port. + +**TCP:** same from step 4 onward, but `handle_tcp` reads/writes with an MBAP +header (no CRC) instead of RTU framing. + +**Dual-serial:** `handle_serial()` services two ports with dedicated RX buffers +(`mb_rx_dbg` / `mb_rx_rtu`); `mb_frame` is only transient process/TX scratch. + +## Invariants + +1. **Transports do not know the function-code set.** They ask `modbus_pdu` + (`mb_pdu_request_len` + `mb_pdu_skips_crc`). Adding a function code touches + only `modbus_debug` (the handler) and `modbus_pdu` (dispatch + shape) — never + the transports. +2. **`mb_frame` is the one seam.** Every transport fills it, calls + `process_mbpacket()`, and reads the response back out. Single-threaded + cooperative scheduling means the transports time-slice within a scan; there + are no data races, but persistent partial state in `mb_frame` is a hazard — + see the note below. + +## Adding a function code (e.g. custom `0x49+`) + +1. Add the handler in **`modbus_debug.cpp`** (+ prototype in `modbus_debug.h`). +2. In **`modbus_pdu.cpp`**: + - add a `case` in `process_mbpacket()` that calls the handler; + - add the FC's request length to `mb_pdu_request_len()`; + - if the FC should bypass CRC on RTU, add it to `mb_pdu_skips_crc()`. +3. Add the FC constant to the enum in **`modbus_types.h`**. + +That is the whole surface. `modbus_serial.*` and `modbus_tcp.*` are untouched. + +## Known constraint — single-serial + TCP + +`mb_frame` is shared between `handle_tcp()` and the single-serial assembly path. +In **single-serial** builds `mb_frame` doubles as the RX-assembly buffer and +holds a partial RTU/debug frame **across scan cycles**; since `mbtask()` runs +`handle_tcp()` first, an incoming TCP request can clobber that partial frame. +The framing logic resyncs, but the in-flight transaction is lost → intermittent +glitches under concurrent TCP load. Dual-serial + TCP is safe (dedicated RX +buffers; `mb_frame` only transient). The original design assumed a single Modbus +operation transport per board; the editor allowing RTU + TCP together violates +that. Fix is planned separately (dedicated single-serial RX buffer scoped to +`MBSERIAL && MBTCP`). From 231e11d9bde73ff2ab94aba26b1de54602c64c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 12:04:12 +0200 Subject: [PATCH 19/79] feat(editor): one device connection over serial or Modbus TCP, with run/stop Brings the connection work that the VPP licensing branch was carrying as an implicit dependency onto its own footing, with no licensing in it. DeviceLinkManager owns THE connection: it takes the ordered candidates resolved from the board's debug spec, tries them in order, and keeps the first that both opens and verifies. Modbus TCP is preferred when the project enables it and serial is always a candidate, since the always-on debugger keeps the serial protocol compiled into every baremetal firmware. Verification per candidate is what makes preferring TCP safe: a socket that opens but answers nothing falls through to the cable instead of stranding the user. Every command -- debug reads and writes, run/stop, md5, the status poll -- now borrows that one client; none of them can open a connection. That is what fixes run/stop over Modbus TCP: the command path used to recognise only an RTU client as reusable, so a tcp stop opened a transient SECOND socket, which an Arduino Modbus TCP server (one client at a time) never answers. Run/stop (FC 0x4b PLC_SET_STATE) rides the same link. Reads come from the status frame (FC 0x46), which already carries the run/stop state and the mode-switch position, so a switch flipped by hand shows up within one poll interval with no second timer and no extra traffic. A RUN request is refused -- not queued -- while the switch reads STOP, and `refusedBySwitch` says so. Session structure: two channel slots (control + debug), lazily opened, so a debug session shares the link rather than replacing it. Runtime v3/v4 come into the same session; the runtime-v4 WebSocket keeps its own client, being a different protocol to a different target. Also here: serial-port descriptors carried as data rather than a display string, a connection-lost warning that names the endpoint and the right advice per transport, and an upload that releases the link only when it is the serial one holding that port -- so a Modbus TCP link, and the debug session on it, survives an upload. Two fixes that are prerequisites rather than features: validate:arch now resolves SRC_ROOT via fileURLToPath (URL.pathname produced C:\C:\... on win32 and the check could not scan the tree at all), and it learned to read multi-line imports, which it never could -- so it had been reporting success while missing real violations of its own rules. No licensing: no license blob, no on-device license storage, no activation, no FC 0x48-0x4C licensing set, no isLicensable/licenseStore capabilities. The device screen confirms a held link and nothing more. `classifyDeviceLink` (backend/editor/hardware/device-probe.ts) keeps only the board-id read that says whether an OpenPLC firmware answered. Co-Authored-By: Claude Opus 5 --- resources/sources/Baremetal/Baremetal.ino | 7 + resources/sources/Baremetal/modbus_debug.cpp | 42 +- resources/sources/Baremetal/modbus_debug.h | 8 +- resources/sources/Baremetal/modbus_pdu.cpp | 8 + resources/sources/Baremetal/modbus_tcp.cpp | 48 +- resources/sources/Baremetal/modbus_types.h | 6 + .../sources/arduino/arduino_runtime_glue.cpp | 192 +++- .../sources/arduino/arduino_runtime_glue.h | 41 + resources/sources/arduino/openplc.h | 41 + src/__architecture__/validate.ts | 76 +- .../editor/compiler/compiler-module.ts | 27 + .../__tests__/device-link-policy.test.ts | 129 +++ .../__tests__/device-session-manager.test.ts | 632 +++++++++++ .../__tests__/serial-port-list.test.ts | 42 +- .../editor/hardware/device-link-policy.ts | 125 ++ src/backend/editor/hardware/device-probe.ts | 114 ++ .../editor/hardware/device-session-manager.ts | 623 ++++++++++ .../hardware/device-transport-factory.ts | 107 ++ .../editor/hardware/hardware-module.ts | 26 +- .../editor/hardware/serial-port-list.ts | 44 +- src/backend/editor/hardware/types.ts | 3 +- src/backend/editor/modbus/modbus-client.ts | 131 ++- .../editor/modbus/modbus-rtu-client.ts | 94 +- .../__tests__/modbus-pdu-plc-control.test.ts | 137 +++ .../shared/debug/__tests__/modbus-pdu.test.ts | 5 +- src/backend/shared/debug/modbus-pdu.ts | 81 +- src/backend/shared/debug/types.ts | 137 +++ .../shared/debug/websocket-debug-transport.ts | 11 +- .../connect-resolve-regression.test.ts | 312 +++++ src/backend/shared/hardware/debug-spec.ts | 164 +++ .../__tests__/modbus-rtu-client.test.ts | 4 +- .../__tests__/plc-control-e2e.test.ts | 254 +++++ .../shared/simulator/modbus-rtu-client.ts | 42 +- src/backend/shared/simulator/types.ts | 22 + .../editor/device/configuration/board.tsx | 93 +- .../__tests__/device-connect-button.test.tsx | 78 ++ .../device-connect-button/index.tsx | 69 ++ .../modals/confirm-device-switch-modal.tsx | 1 + .../modals/runtime-connection-lost-modal.tsx | 14 +- .../workspace-activity-bar/default.tsx | 523 +++++---- .../components/_templates/app-layout.tsx | 2 + .../__tests__/use-device-connect.test.ts | 209 ++++ .../use-device-connection-monitor.test.ts | 177 +++ .../__tests__/use-device-plc-state.test.ts | 92 ++ .../__tests__/use-runtime-polling.test.ts | 2 + src/frontend/hooks/use-device-connect.ts | 127 +++ .../hooks/use-device-connection-monitor.ts | 128 +++ src/frontend/hooks/use-device-plc-state.ts | 61 + src/frontend/hooks/use-runtime-polling.ts | 7 +- src/frontend/hooks/useDebugSession.ts | 44 +- src/frontend/screens/workspace-screen.tsx | 6 + .../__tests__/device-link-resolution.test.ts | 111 ++ .../services/device-link-resolution.ts | 319 ++++++ .../store/__tests__/device-slice.test.ts | 50 +- .../store/__tests__/device-types.test.ts | 23 + src/frontend/store/slices/device/index.ts | 2 + src/frontend/store/slices/device/slice.ts | 42 +- src/frontend/store/slices/device/types.ts | 49 + .../utils/__tests__/serial-port-label.test.ts | 98 ++ src/frontend/utils/device-connect-events.ts | 23 + src/frontend/utils/serial-port-label.ts | 41 + src/main/modules/ipc/main.ts | 1011 ++++++++++------- src/main/modules/ipc/renderer.ts | 98 +- .../editor/__tests__/debugger-adapter.test.ts | 113 +- .../editor/__tests__/device-adapter.test.ts | 33 +- .../adapters/editor/debugger-adapter.ts | 29 +- .../adapters/editor/device-adapter.ts | 46 +- src/middleware/shared/ports/debugger-port.ts | 28 +- src/middleware/shared/ports/device-port.ts | 126 +- src/middleware/shared/ports/runtime-port.ts | 5 + src/middleware/shared/ports/types.ts | 18 +- src/middleware/shared/utils/debug-endpoint.ts | 15 + .../utils/target-capabilities/presets.ts | 4 + .../utils/target-capabilities/resolve.ts | 1 + .../shared/utils/target-capabilities/types.ts | 7 + 75 files changed, 6769 insertions(+), 891 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/device-link-policy.test.ts create mode 100644 src/backend/editor/hardware/__tests__/device-session-manager.test.ts create mode 100644 src/backend/editor/hardware/device-link-policy.ts create mode 100644 src/backend/editor/hardware/device-probe.ts create mode 100644 src/backend/editor/hardware/device-session-manager.ts create mode 100644 src/backend/editor/hardware/device-transport-factory.ts create mode 100644 src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts create mode 100644 src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts create mode 100644 src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts create mode 100644 src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx create mode 100644 src/frontend/components/_molecules/device-connect-button/index.tsx create mode 100644 src/frontend/hooks/__tests__/use-device-connect.test.ts create mode 100644 src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts create mode 100644 src/frontend/hooks/__tests__/use-device-plc-state.test.ts create mode 100644 src/frontend/hooks/use-device-connect.ts create mode 100644 src/frontend/hooks/use-device-connection-monitor.ts create mode 100644 src/frontend/hooks/use-device-plc-state.ts create mode 100644 src/frontend/services/__tests__/device-link-resolution.test.ts create mode 100644 src/frontend/services/device-link-resolution.ts create mode 100644 src/frontend/utils/__tests__/serial-port-label.test.ts create mode 100644 src/frontend/utils/device-connect-events.ts create mode 100644 src/frontend/utils/serial-port-label.ts create mode 100644 src/middleware/shared/utils/debug-endpoint.ts diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index ce3de63b7..8ff5c460c 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -128,6 +128,13 @@ void setup() // Initialize hardware (HAL -- unchanged) hardwareInit(); + // Establish the run/stop state. Must follow hardwareInit() so the HAL has + // already configured its mode-switch pin: a board powered up with the + // switch in STOP must never execute a scan. Boards with no mode switch + // read RUN and start immediately, as they always have. + runtime_init_plc_state(); + + #ifdef MODBUS_ENABLED #ifdef MBSERIAL #ifdef MBSERIAL_ON_SECONDARY diff --git a/resources/sources/Baremetal/modbus_debug.cpp b/resources/sources/Baremetal/modbus_debug.cpp index d6d586285..85a56df5a 100644 --- a/resources/sources/Baremetal/modbus_debug.cpp +++ b/resources/sources/Baremetal/modbus_debug.cpp @@ -10,6 +10,9 @@ Copyright (C) 2022 OpenPLC - Thiago Alves // standard. The shims forward to strucpp::debug::handle_* inside // arduino_runtime_glue.cpp (part of the precompiled OpenPLCUserLib archive). #include "arduino_runtime_glue.h" +// PLC_STATE_* / PLC_SWITCH_* and runtime_get_plc_state(), for the run/stop +// reporting in debugGetStatus() and plcSetState(). +#include "openplc.h" #include "openplc_version.h" // ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. @@ -339,7 +342,11 @@ void debugGetStatus() mb_frame[1] = MB_FC_DEBUG_GET_STATUS; mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = 1; // PLC scan is always running on baremetal + // The real run/stop state, not a constant: the baremetal runtime has a + // state machine now (see arduino_runtime_glue.h). This byte being the + // state is why there is no separate query function code -- the editor's + // status poll already carries it. + mb_frame[3] = runtime_get_plc_state(); mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); @@ -348,7 +355,38 @@ void debugGetStatus() mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); mb_frame[11] = (uint8_t)(uptime & 0xFF); - mb_frame_len = 12; + // Mode-switch position, appended so the editor can gate a start locally. + // Boards with no physical switch report RUN, so a caller needs no "absent" + // case; an older editor that stops reading at byte 11 simply ignores it. + mb_frame[12] = runtime_get_switch_position(); + mb_frame_len = 13; +} + +// PDU request: [FC][state:u8] (0 = STOP, 1 = RUN) +// PDU response: [FC][status][plc_state:u8][switch_position:u8] +// +// Command only -- reading the state is debugGetStatus() (FC 0x46) above, which +// already reports it. A RUN request while the mode switch reads STOP is +// REFUSED, not queued, so the editor tells the user to flip the switch instead +// of leaving a start pending. Stop requests are always honoured. +// +// The reported state is read back after the request is applied, but the runtime +// derives it inside runtime_plc_cycle() -- so on a change the value here is the +// state as of the last cycle and the caller sees the new one on its next status +// poll (at most one scan period later). +void plcSetState(uint8_t desired) +{ + uint8_t status = MB_DEBUG_SUCCESS; + + const uint8_t target = (desired == 0x01) ? PLC_STATE_RUNNING : PLC_STATE_STOPPED; + if (runtime_request_plc_state(target) == PLC_CTRL_REFUSED_SWITCH_STOP) + status = MB_PLC_CTRL_REFUSED_SWITCH; + + mb_frame[1] = MB_FC_PLC_SET_STATE; + mb_frame[2] = status; + mb_frame[3] = runtime_get_plc_state(); + mb_frame[4] = runtime_get_switch_position(); + mb_frame_len = 5; } // PDU request: [FC] diff --git a/resources/sources/Baremetal/modbus_debug.h b/resources/sources/Baremetal/modbus_debug.h index 1c03df15d..a9cf7d1fc 100644 --- a/resources/sources/Baremetal/modbus_debug.h +++ b/resources/sources/Baremetal/modbus_debug.h @@ -1,11 +1,10 @@ /* -modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x48) +modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x48, 0x4B) Copyright (C) 2022 OpenPLC - Thiago Alves The debugger PDU handlers, dispatched from process_mbpacket. Kept ungated: the dispatch in modbus_pdu references them unconditionally (the always-on debugger is -present on every baremetal build). This is the growth home for future custom FCs -(e.g. the 0x49-0x4C licensing set). +present on every baremetal build). This is the growth home for future custom FCs. */ #ifndef MODBUS_DEBUG_H @@ -25,5 +24,8 @@ void debugGetMd5(void *endianness); void debugGetStatus(void); void debugGetVersion(void); void debugGetBoardId(void); +// FC 0x4B -- set the runtime run/stop state. Command only; the state is read +// back through debugGetStatus (FC 0x46), which reports it. +void plcSetState(uint8_t desired); #endif diff --git a/resources/sources/Baremetal/modbus_pdu.cpp b/resources/sources/Baremetal/modbus_pdu.cpp index ba1c963d2..c3a480676 100644 --- a/resources/sources/Baremetal/modbus_pdu.cpp +++ b/resources/sources/Baremetal/modbus_pdu.cpp @@ -41,6 +41,8 @@ int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n) case MB_FC_DEBUG_GET_VERSION: case MB_FC_DEBUG_GET_BOARD_ID: return 4; // [id][fc][crc:2] + case MB_FC_PLC_SET_STATE: + return 5; // [id][fc][state:1][crc:2] default: return -1; // not one of our function codes } @@ -176,6 +178,12 @@ void process_mbpacket() debugGetBoardId(); break; + case MB_FC_PLC_SET_STATE: + // PDU: [FC:1][state:u8] (0 = STOP, 1 = RUN) + plcSetState(mb_frame[2]); + break; + + default: exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); } diff --git a/resources/sources/Baremetal/modbus_tcp.cpp b/resources/sources/Baremetal/modbus_tcp.cpp index 453267469..ec9e88afd 100644 --- a/resources/sources/Baremetal/modbus_tcp.cpp +++ b/resources/sources/Baremetal/modbus_tcp.cpp @@ -161,7 +161,29 @@ void handle_tcp() mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + // Smallest legal frame is [unit][fc] = 2. The old floor of 6 was + // the minimum for a standard DATA request ([unit][fc][addr:2] + // [qty:2]), so it silently dropped any request SHORTER than that + // before process_mbpacket() ever saw it: + // + // 0x41 debug-info len 2 dropped + // 0x46 status len 2 dropped (run/stop state + switch) + // 0x47 version len 2 dropped + // 0x48 board id len 2 dropped (Connect's verification) + // 0x4b run/stop len 3 dropped (the Stop/Run button) + // 0x44 get-list len 4+3n passed + // 0x45 md5 len 6 passed + // + // Which is why this went unnoticed for so long: a debug SESSION + // only uses 0x44 and 0x45, so debugging over Modbus TCP worked + // fine, while Connect and run/stop over TCP could never work. The + // floor predates the split of the ModbusSlave monolith (it was in + // there twice, verbatim) and was harmless until function codes + // with no payload were introduced. + // + // Per-FC shape is validated in process_mbpacket(); over TCP the + // MBAP length is authoritative, there being no CRC to check. + if (mb_frame_len < 2 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big j = 0; while (mb_serverClients[i].available()) @@ -212,7 +234,29 @@ void handle_tcp() mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + // Smallest legal frame is [unit][fc] = 2. The old floor of 6 was + // the minimum for a standard DATA request ([unit][fc][addr:2] + // [qty:2]), so it silently dropped any request SHORTER than that + // before process_mbpacket() ever saw it: + // + // 0x41 debug-info len 2 dropped + // 0x46 status len 2 dropped (run/stop state + switch) + // 0x47 version len 2 dropped + // 0x48 board id len 2 dropped (Connect's verification) + // 0x4b run/stop len 3 dropped (the Stop/Run button) + // 0x44 get-list len 4+3n passed + // 0x45 md5 len 6 passed + // + // Which is why this went unnoticed for so long: a debug SESSION + // only uses 0x44 and 0x45, so debugging over Modbus TCP worked + // fine, while Connect and run/stop over TCP could never work. The + // floor predates the split of the ModbusSlave monolith (it was in + // there twice, verbatim) and was harmless until function codes + // with no payload were introduced. + // + // Per-FC shape is validated in process_mbpacket(); over TCP the + // MBAP length is authoritative, there being no CRC to check. + if (mb_frame_len < 2 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big i = 0; while (client.available()) diff --git a/resources/sources/Baremetal/modbus_types.h b/resources/sources/Baremetal/modbus_types.h index f75e3af8e..6eb6ebd5f 100644 --- a/resources/sources/Baremetal/modbus_types.h +++ b/resources/sources/Baremetal/modbus_types.h @@ -41,6 +41,11 @@ protocol, transport, register and debug layers agree on the same contracts. #define MB_DEBUG_SUCCESS 0x7E #define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 #define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 +// MB_FC_PLC_SET_STATE only: a RUN request was refused because the hardware mode +// switch reads STOP. The editor turns this into a "flip the switch to RUN" +// warning rather than a generic failure. It doesn't collide with Modbus +// exceptions (0x01-0x04) nor 0x7E/0x81/0x82. +#define MB_PLC_CTRL_REFUSED_SWITCH 0x86 //Modbus registers struct struct MBinfo { @@ -77,6 +82,7 @@ enum { MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID + MB_FC_PLC_SET_STATE = 0x4B, // Set the runtime run/stop state }; //Exception Codes diff --git a/resources/sources/arduino/arduino_runtime_glue.cpp b/resources/sources/arduino/arduino_runtime_glue.cpp index 37ed22dae..2967f9d45 100644 --- a/resources/sources/arduino/arduino_runtime_glue.cpp +++ b/resources/sources/arduino/arduino_runtime_glue.cpp @@ -19,6 +19,15 @@ #include "generated.hpp" #include "debug_dispatch.hpp" +// Placement new, used by runtime_reinit_program() to re-run the program's +// initializers over storage that already exists. Available on every target the +// editor builds for, AVR included (the bundled avr-libstdcpp ships , and +// the strucpp headers above already pull it in transitively via ). +// Note this is the PLACEMENT form only -- it allocates nothing. +#include +// std::is_trivially_destructible, for the diagnostic static_assert below. +#include + // --------------------------------------------------------------------------- // Runtime fault hook // --------------------------------------------------------------------------- @@ -46,6 +55,53 @@ static size_t total_programs = 0; unsigned long long base_tick_ns = 20000000ULL; uint32_t scan_counter = 0; +// --------------------------------------------------------------------------- +// Run/stop state. See the contract comment in arduino_runtime_glue.h. +// +// `software_stop` is the latch set by runtime_request_plc_state(); `plc_state` +// is derived from it plus the switch every cycle, so it is never written from +// anywhere but runtime_plc_cycle() / runtime_init_plc_state(). +// --------------------------------------------------------------------------- +static uint8_t plc_state = PLC_STATE_RUNNING; +static uint8_t switch_position = PLC_SWITCH_RUN; +static uint8_t last_switch = PLC_SWITCH_RUN; +static bool software_stop = false; + +// Weak default: boards with no physical mode switch always read RUN, so the +// gate collapses to "software request only" and the boot state is RUNNING -- +// identical to the behaviour before this interface existed. A VPP HAL +// provides a strong extern "C" override. +extern "C" __attribute__((weak)) uint8_t hardwareStateSwitch(void) +{ + return PLC_SWITCH_RUN; +} + +extern "C" uint8_t runtime_get_plc_state(void) +{ + return plc_state; +} + +extern "C" uint8_t runtime_get_switch_position(void) +{ + return switch_position; +} + +extern "C" uint8_t runtime_request_plc_state(uint8_t desired_state) +{ + if (desired_state == PLC_STATE_RUNNING) { + // Hardware is authoritative: refuse rather than queue, so the caller + // can tell the user to flip the switch instead of silently waiting. + if (hardwareStateSwitch() == PLC_SWITCH_STOP) return PLC_CTRL_REFUSED_SWITCH_STOP; + software_stop = false; + return PLC_CTRL_OK; + } + if (desired_state == PLC_STATE_STOPPED) { + software_stop = true; + return PLC_CTRL_OK; + } + return PLC_CTRL_INVALID; +} + // --------------------------------------------------------------------------- // GCD utility — used by discoverTasks for the base-tick computation // --------------------------------------------------------------------------- @@ -235,25 +291,147 @@ void runtime_apply_located_forces() } // --------------------------------------------------------------------------- -// One scan cycle: copy inputs → run scheduled programs → copy outputs → -// advance IEC TIME() so TON/TOF/TP can progress. +// De-energise the output image. +// +// Called every cycle while stopped, immediately before updateOutputBuffers() +// pushes the image to hardware. Two consequences worth keeping in mind: +// +// - A Modbus client writing coils between cycles cannot energise a physical +// output while stopped: its write lands in the image and is zeroed here +// before the HAL ever sees it. +// - Memory areas (int_memory / dint_memory / lint_memory) are deliberately +// NOT cleared. They are not physical outputs. +// +// The image slots alias the located variables' IECVar storage, so this also +// zeroes the program's own %QX / %QW / %QD variables. That is intended: a +// stopped PLC holds no output state. +// --------------------------------------------------------------------------- +static void runtime_zero_output_image() +{ + for (int i = 0; i < MAX_DIGITAL_OUTPUT; ++i) { + if (bool_output[i / 8][i % 8]) *bool_output[i / 8][i % 8] = 0; + } + for (int i = 0; i < MAX_ANALOG_OUTPUT; ++i) { + if (int_output[i]) *int_output[i] = 0; + } +#if !defined(__AVR_ATmega328P__) && !defined(__AVR_ATmega168__) && !defined(__AVR_ATmega32U4__) && !defined(__AVR_ATmega16U4__) + for (int i = 0; i < MAX_REAL_OUTPUT; ++i) { + if (real_output[i]) *real_output[i] = 0.0f; + } +#endif +} + +// --------------------------------------------------------------------------- +// Cold-stop the program: re-run every IEC initial value so the next start +// begins at cycle 1 rather than resuming mid-flight. +// +// NO DYNAMIC ALLOCATION. g_config is a file-scope object with static storage +// duration (.bss/.data), and placement new constructs into that existing +// storage — it calls neither malloc nor operator new(size_t). Everything the +// generated Configuration holds is by value and fixed size, and nothing in +// the strucpp runtime allocates (IECVar is three value members; IEC_STRING is +// a fixed char array). +// +// Every pointer into g_config survives, because placement new reuses the same +// storage with the same layout: locatedVars[i].pointer, the image-table slots, +// the ProgramBase* entries cached in all_programs[] and in the configuration's +// own task_programs_storage[], and the flash-resident Entry tables in +// generated_debug.cpp that hold raw void* into g_config members. +// +// runtime_discover_tasks() is deliberately NOT re-run: it new[]-allocates +// all_programs / task_divisors, so calling it twice would leak. The tables it +// built stay correct. +// +// Two documented consequences: debugger forces are cleared (force state lives +// inside each IECVar), and a program using the explicit IEC NEW operator must +// DELETE before stopping or it leaks across restarts — nothing frees those +// allocations automatically, at re-init or otherwise. +// --------------------------------------------------------------------------- +static void runtime_reinit_program() +{ + // Destroy then re-construct in place. The destructor call matters: + // Configuration_CONFIG0 derives from strucpp::ConfigurationInstance, which + // declares `virtual ~ConfigurationInstance() = default` (iec_std_lib.hpp), + // so the type is NOT trivially destructible even though it owns nothing. + // Pairing the destructor with the placement new is correct either way -- + // for a defaulted virtual destructor it compiles to nothing, and if a + // future strucpp change adds a genuinely owning member it runs that + // member's cleanup instead of leaking it. Neither call allocates. + g_config.~Configuration_CONFIG0(); + new (&g_config) strucpp::Configuration_CONFIG0(); + + runtime_zero_output_image(); + runtime_bind_located_vars(); // idempotent, allocation-free + scan_counter = 0; +} + +// --------------------------------------------------------------------------- +// Establish the initial state. Called once from setup(), after hardwareInit() +// so the HAL's switch pin is already configured. +// --------------------------------------------------------------------------- +void runtime_init_plc_state() +{ + switch_position = hardwareStateSwitch(); + last_switch = switch_position; + software_stop = false; + plc_state = (switch_position == PLC_SWITCH_STOP) ? PLC_STATE_STOPPED : PLC_STATE_RUNNING; +} + +// --------------------------------------------------------------------------- +// One scan cycle: resolve run/stop → copy inputs → run scheduled programs → +// copy outputs → advance IEC TIME() so TON/TOF/TP can progress. +// +// While stopped the loop keeps cycling: inputs are still refreshed (so the +// debugger and Modbus clients see live field data during commissioning), +// outputs stay de-energised, updateOutputBuffers() is still called (so a HAL +// driving a status LED from it stays correct), and IEC time is frozen. // --------------------------------------------------------------------------- void runtime_plc_cycle() { + // 1. Resolve the state from the mode switch and the software latch. + const uint8_t sw = hardwareStateSwitch(); + // A physical flip to RUN always puts the PLC in RUN — clearing a software + // stop, so the switch is never overridden by a stale editor command. + if (sw == PLC_SWITCH_RUN && last_switch == PLC_SWITCH_STOP) software_stop = false; + last_switch = sw; + switch_position = sw; + + const uint8_t new_state = + (sw == PLC_SWITCH_STOP || software_stop) ? PLC_STATE_STOPPED : PLC_STATE_RUNNING; + + // Entering STOP is a cold stop: zero the outputs and re-initialise the + // program exactly once, on the transition. + if (new_state == PLC_STATE_STOPPED && plc_state != PLC_STATE_STOPPED) { + runtime_reinit_program(); + } + plc_state = new_state; + + // 2. Inputs, in both states. updateInputBuffers(); // HAL just wrote raw input storage directly — re-impose any forced input. runtime_apply_located_forces(); - for (size_t i = 0; i < total_programs; ++i) { - if (task_divisors[i] == 0 || (scan_counter % task_divisors[i]) == 0) { - all_programs[i]->run(); + if (plc_state == PLC_STATE_RUNNING) { + for (size_t i = 0; i < total_programs; ++i) { + if (task_divisors[i] == 0 || (scan_counter % task_divisors[i]) == 0) { + all_programs[i]->run(); + } } + ++scan_counter; + } else { + // Re-zero every stopped cycle, not just on the transition: a Modbus + // client may have written coils into the image since the last cycle. + runtime_zero_output_image(); } - ++scan_counter; + // 3. Outputs, in both states — zeros while stopped. updateOutputBuffers(); - strucpp::__CURRENT_TIME_NS += (int64_t)base_tick_ns; + // 4. IEC time advances only while running, so TON/TOF/TP resume where + // they left off instead of jumping by the stop duration. + if (plc_state == PLC_STATE_RUNNING) { + strucpp::__CURRENT_TIME_NS += (int64_t)base_tick_ns; + } } // --------------------------------------------------------------------------- diff --git a/resources/sources/arduino/arduino_runtime_glue.h b/resources/sources/arduino/arduino_runtime_glue.h index 0ffe2ca42..1aa1b59e4 100644 --- a/resources/sources/arduino/arduino_runtime_glue.h +++ b/resources/sources/arduino/arduino_runtime_glue.h @@ -39,9 +39,50 @@ extern uint32_t scan_counter; void runtime_bind_located_vars(); void runtime_discover_tasks(); +// Establish the initial run/stop state. Call once from setup() AFTER +// hardwareInit(), so the HAL has already configured its switch pin. Reads +// the mode switch: a board powered up with the switch in STOP never +// executes a scan. +void runtime_init_plc_state(); + // Per-cycle helpers (call once per scan cycle from scheduler()/loop()). void runtime_plc_cycle(); +// --------------------------------------------------------------------------- +// Run/stop control surface. +// +// State is derived every cycle from the mode switch (hardwareStateSwitch(), +// PLC_SWITCH_RUN when no HAL implements it) and a software-request latch set +// through runtime_request_plc_state(): +// +// switch software request state +// ------ ---------------- ----- +// RUN run (default) RUNNING <- every board with no switch +// RUN stop STOPPED +// STOP (ignored) STOPPED <- hardware is authoritative +// +// A STOP -> RUN edge on the switch resets the software request to `run`, so +// a physical flip to RUN always puts the PLC in RUN -- otherwise a +// software-stopped device would sit dead in the RUN position with no local +// way to recover. +// +// runtime_get_plc_state() is declared in openplc.h because HALs call it to +// drive a status LED. +// --------------------------------------------------------------------------- + +// Result codes for runtime_request_plc_state(). +#define PLC_CTRL_OK 0 +#define PLC_CTRL_REFUSED_SWITCH_STOP 1 +#define PLC_CTRL_INVALID 2 + +// Last value read from hardwareStateSwitch() (PLC_SWITCH_*). +uint8_t runtime_get_switch_position(void); + +// Ask for PLC_STATE_RUNNING or PLC_STATE_STOPPED. A request to run while the +// mode switch reads STOP is REFUSED, not queued -- the caller reports that +// to the user rather than retrying. Returns PLC_CTRL_*. +uint8_t runtime_request_plc_state(uint8_t desired_state); + // Re-impose forced located variables' values onto their raw storage. Call // after any code path that writes the image pointers directly (HAL input // refresh, Modbus reverse-copy) so a debugger force is not clobbered. Cheap diff --git a/resources/sources/arduino/openplc.h b/resources/sources/arduino/openplc.h index a9cde6270..eafb41d39 100644 --- a/resources/sources/arduino/openplc.h +++ b/resources/sources/arduino/openplc.h @@ -74,6 +74,20 @@ extern IEC_ULINT *lint_memory[MAX_MEMORY_LWORD]; #endif +/*********************/ +/* Run/stop state */ +/*********************/ + +// Mode-switch positions reported by hardwareStateSwitch(). +#define PLC_SWITCH_STOP 0 +#define PLC_SWITCH_RUN 1 + +// Externally visible runtime states, as reported by runtime_get_plc_state() +// and over Modbus FC 0x49. +#define PLC_STATE_STOPPED 0 +#define PLC_STATE_RUNNING 1 +#define PLC_STATE_ERROR 2 + //Hardware Layer (implemented in arduino.cpp HAL file, compiled as extern "C") #ifdef __cplusplus extern "C" { @@ -81,6 +95,33 @@ extern "C" { void hardwareInit(); void updateInputBuffers(); void updateOutputBuffers(); + +/* ---- Optional: physical mode switch ------------------------------------ + * Weak default in arduino_runtime_glue.cpp returns PLC_SWITCH_RUN, so a HAL + * that does not define this behaves exactly as before this interface + * existed: the runtime boots into RUNNING and the editor has full software + * control. + * + * Override with a strong extern "C" definition in the HAL .cpp -- the same + * mechanism the P1AM HAL already uses for strucpp::iec_runtime_fault. + * + * Called once per scan cycle, in every state, from the scan path. MUST + * return quickly and MUST NOT block. HOW it does so is the HAL's decision: + * a GPIO is cheap enough to read synchronously, while a switch behind a + * slow bus (I2C expander, fieldbus backplane) should be sampled elsewhere + * and returned here from a cached value. The runtime never polls on the + * HAL's behalf and never imposes a sampling period. + * ---------------------------------------------------------------------- */ +uint8_t hardwareStateSwitch(void); + +/* ---- Optional: state indication ---------------------------------------- + * There is no indication callback. The runtime holds the state; a HAL with + * a status LED reads it inside updateOutputBuffers() (which the runtime + * calls every cycle in every state, so the LED is correct from the first + * cycle even on a board that boots into STOP) and drives its own pin. A + * HAL with no LED reads nothing and the runtime never knows the difference. + * ---------------------------------------------------------------------- */ +uint8_t runtime_get_plc_state(void); #ifdef __cplusplus } #endif diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 8070dc4b8..333812800 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -10,6 +10,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' // --------------------------------------------------------------------------- // Layer definitions @@ -118,7 +119,12 @@ const LAYER_RULES: Record = { // Helpers // --------------------------------------------------------------------------- -const SRC_ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..') +// `new URL(...).pathname` yields a URL-encoded path that, on win32, carries a +// leading slash before the drive letter (/C:/Users/...). Passing that into +// resolve() prepends the current drive, producing a doubled C:\C:\Users\... +// prefix — which made validate:arch fail to even scan the tree. fileURLToPath +// does the file:// -> filesystem conversion correctly on every platform. +const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') function collectFiles(dir: string, ext: string[]): string[] { const results: string[] = [] @@ -180,36 +186,25 @@ function getLayer(filePath: string): LayerName | null { return null } -/** Extract import/export-from paths from a TypeScript source string */ +/** + * Every `import ... from '...'` / `export ... from '...'` / bare `import '...'`, + * with the line it starts on. + * + * Scans the whole source rather than line by line: a MULTI-LINE import — the + * default once a statement names more than a couple of symbols — puts the + * `import` keyword and the module path on different lines, so a per-line regex + * silently sees neither. That blind spot hid real violations of these very rules, + * which is worse than having no gate, because the gate reported success. + */ function extractImports(source: string): { path: string; line: number }[] { const results: { path: string; line: number }[] = [] - const lines = source.split('\n') - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - // Static imports: import ... from '...' - // Re-exports: export ... from '...' - const staticMatch = line.match(/(?:import|export)\s+.*?\s+from\s+['"]([^'"]+)['"]/) - if (staticMatch) { - results.push({ path: staticMatch[1], line: i + 1 }) - continue - } - - // Side-effect imports: import '...' - const sideEffectMatch = line.match(/^\s*import\s+['"]([^'"]+)['"]/) - if (sideEffectMatch) { - results.push({ path: sideEffectMatch[1], line: i + 1 }) - continue - } - - // Dynamic imports: import('...') - const dynamicMatch = line.match(/import\(\s*['"]([^'"]+)['"]\s*\)/) - if (dynamicMatch) { - results.push({ path: dynamicMatch[1], line: i + 1 }) - } + const pattern = /(?:^|\n)\s*(?:import|export)\b[\s\S]*?from\s+['"]([^'"]+)['"]|(?:^|\n)\s*import\s+['"]([^'"]+)['"]/g + let match: RegExpExecArray | null + while ((match = pattern.exec(source)) !== null) { + const path = match[1] ?? match[2] + if (!path) continue + results.push({ path, line: source.slice(0, match.index).split('\n').length }) } - return results } @@ -279,6 +274,31 @@ const KNOWN_EXCEPTIONS: Record = { 'frontend/store/slices/ladder/utils/index.ts': ['components'], // Ladder slice — needs nodesBuilder + defaultCustomNodesStyles for rung creation 'frontend/store/slices/ladder/slice.ts': ['components'], + // Device CONNECT flow (D72) — resolves RTU params from the board debug spec + // via the shared `resolveDebugConnection` resolver, same as the activity bar's + // debugger/post-flash paths. + 'frontend/hooks/use-device-connect.ts': ['backend-shared'], + // Baremetal run/stop mirror — maps the PROTOCOL's run/stop and switch wire + // values (`PlcRuntimeState` / `PlcSwitchPosition`, defined next to the RTU + // client that reads them) onto the store's `PlcStatus` union. Same D72 device + // link as the sibling entry above. The alternative is either duplicating the + // numeric constants in the frontend or hoisting the two enums into + // ports/types.ts; both were judged worse than one documented import. + 'frontend/hooks/use-device-plc-state.ts': ['backend-shared'], + // Run/stop control port — `PlcControlResult` is the FC 0x4b acknowledgement + // shape, defined with the protocol types it is built from (`PlcRuntimeState`). + // Type-only import; hoisting it into ports/types.ts would drag the wire enums + // along with it, so the contract stays where the protocol is described. + 'middleware/shared/ports/debugger-port.ts': ['backend-shared'], + // Device connect/debug resolution — interprets the board's declarative `debug` + // spec (backend/shared/hardware/debug-spec.ts), which is the ONE place that spec + // is read. The alternative is a second interpreter in the frontend, which is how + // Connect and the debugger came to disagree about what a spec meant. + 'frontend/services/device-link-resolution.ts': ['backend-shared'], + // Activity bar — resolves the same spec for the post-upload reconnect and the + // debug session. Pre-existing; it was invisible until `extractImports` learned + // to read multi-line imports. + 'frontend/components/_organisms/workspace-activity-bar/default.tsx': ['backend-shared'], // PLCopen export — needs the shared XmlGenerator composing function // (backend/shared/utils/PLC/xml-generator.ts) to turn the converted // project data into XML before handing it to the platform port. No diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index a086d6eb4..99ccebb69 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2510,6 +2510,33 @@ class CompilerModule { }) } } + // An arduino-cli target CANNOT work without its HAL: `hardwareInit` / + // `updateInputBuffers` / `updateOutputBuffers` have no other definition, + // so the build either dies at link with an undefined-reference wall or — + // if anything ever weak-defines them — silently produces firmware that + // drives no I/O at all. Both were observed as "the program runs but the + // outputs never move", with the real cause (the board resolved without a + // HAL, e.g. a VPP whose manifest didn't load) reported only as a warning + // several hundred log lines earlier. + // + // Fail here instead, naming the board and the path, so the message says + // what is wrong rather than what it broke. + // Runtime v3 legitimately has no HAL (its on-device MatIEC compiles the + // ST itself and it never links Arduino firmware), so this only applies to + // targets that actually build a sketch. + if (!boardHalContent && !isRuntimeV3) { + const where = boardInfo.halSourceFile + ? `its HAL source could not be read from ${boardInfo.halSourceFile}` + : 'it did not resolve a HAL source file (a VPP package may have failed to load — try reinstalling it)' + _mainProcessPort.postMessage({ + logLevel: 'error', + message: + `Board "${boardTarget}" cannot be compiled: ${where}. ` + + 'Without a HAL the firmware has no hardware I/O layer.\nStopping compilation process.', + }) + _mainProcessPort.close() + return + } // Re-key strucpp runtime headers from // `strucpp_runtime/include/X` into `src/X` so arduino-cli's // `--library src` pass finds them; also drop the board HAL diff --git a/src/backend/editor/hardware/__tests__/device-link-policy.test.ts b/src/backend/editor/hardware/__tests__/device-link-policy.test.ts new file mode 100644 index 000000000..643f01df9 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-link-policy.test.ts @@ -0,0 +1,129 @@ +/** + * The held link's counting rules — what a user feels as "it recovered by itself" + * vs "it gave up too early", and the only part of the connection manager that can + * be checked without a cable to pull. + */ +import { DeviceLinkPolicy } from '../device-link-policy' + +/** Production shape: 2 silent polls enter recovery, 2 failed reopens give up. */ +const newPolicy = () => new DeviceLinkPolicy(2, 2) + +const enterRecovery = () => { + const policy = newPolicy() + policy.onProbeResult('unresponsive') + policy.onProbeResult('unresponsive') + return policy +} + +describe('DeviceLinkPolicy', () => { + describe('a vanished endpoint fails immediately', () => { + it('does not spend the failure budget first', () => { + // A pulled USB cable is not a slow device. There is nothing to retry + // against, so the user hears about it on the very first tick. + const policy = newPolicy() + expect(policy.onProbeResult('gone')).toBe('fail-now') + expect(policy.recovering).toBe(false) + }) + + it('fails immediately even mid-recovery', () => { + // Recovering from noise, and then the port disappears outright: stop + // retrying and say so. + const policy = enterRecovery() + expect(policy.onProbeResult('gone')).toBe('fail-now') + expect(policy.recovering).toBe(false) + }) + + it('leaves the policy reusable for the next connect', () => { + const policy = newPolicy() + policy.onProbeResult('gone') + expect(policy.attempts).toBe(0) + expect(policy.onProbeResult('alive')).toBe('continue') + }) + }) + + describe('while healthy', () => { + it('stays healthy as long as the device answers', () => { + const policy = newPolicy() + for (let i = 0; i < 50; i++) expect(policy.onProbeResult('alive')).toBe('continue') + expect(policy.recovering).toBe(false) + }) + + it('tolerates a single silent poll', () => { + // Reopening a serial port resets an AVR board, so one dropped frame must + // not restart the user's program. + const policy = newPolicy() + expect(policy.onProbeResult('unresponsive')).toBe('continue') + expect(policy.recovering).toBe(false) + }) + + it('enters recovery on the configured number of consecutive failures', () => { + const policy = newPolicy() + policy.onProbeResult('unresponsive') + expect(policy.onProbeResult('unresponsive')).toBe('enter-recovery') + expect(policy.recovering).toBe(true) + }) + + it('requires the failures to be CONSECUTIVE', () => { + // Alternating silence and answers is a noisy link, not a dead one. + const policy = newPolicy() + for (let i = 0; i < 10; i++) { + expect(policy.onProbeResult('unresponsive')).toBe('continue') + expect(policy.onProbeResult('alive')).toBe('continue') + } + expect(policy.recovering).toBe(false) + }) + }) + + describe('while recovering', () => { + it('gives up quickly rather than retrying for half a minute', () => { + const policy = enterRecovery() + expect(policy.onReopenResult(false)).toBe('retry') + expect(policy.onReopenResult(false)).toBe('give-up') + expect(policy.recovering).toBe(false) + }) + + it('recovers at any point in the window and returns to healthy', () => { + const policy = enterRecovery() + policy.onReopenResult(false) + + expect(policy.onReopenResult(true)).toBe('recovered') + expect(policy.recovering).toBe(false) + expect(policy.attempts).toBe(0) + // Full budget again, not one poll away from another recovery. + expect(policy.onProbeResult('unresponsive')).toBe('continue') + }) + + it('gives a full window to a second outage', () => { + const policy = enterRecovery() + policy.onReopenResult(false) + policy.onReopenResult(true) + + policy.onProbeResult('unresponsive') + expect(policy.onProbeResult('unresponsive')).toBe('enter-recovery') + expect(policy.onReopenResult(false)).toBe('retry') + expect(policy.onReopenResult(false)).toBe('give-up') + }) + + it('counts an attempt that could not even be made', () => { + // No candidate could be built: if that did not count, recovery would spin + // forever and the user would never be told. + const policy = enterRecovery() + policy.onReopenResult(false) + expect(policy.onReopenResult(false)).toBe('give-up') + }) + }) + + describe('reset', () => { + it('returns a recovering policy to healthy', () => { + // A fresh Connect supersedes whatever the previous link was doing. + const policy = enterRecovery() + policy.onReopenResult(false) + + policy.reset() + + expect(policy.recovering).toBe(false) + expect(policy.attempts).toBe(0) + expect(policy.onProbeResult('unresponsive')).toBe('continue') + }) + }) +}) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts new file mode 100644 index 000000000..7c964bc5d --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -0,0 +1,632 @@ +/** + * The single held connection: candidate fallback, one owner for every command, + * and what happens when the endpoint goes away. + * + * `tick()` is driven directly rather than through the poll timer, so the + * sequences here are the real ones a cable pull produces, without the waiting. + */ +import { + DeviceSessionManager, + type DeviceLinkCandidate, + type DeviceLinkHooks, + type DeviceLinkStatus, +} from '../device-session-manager' +import type { DeviceModbusTransport } from '../../../shared/debug/types' + +/** A client that records open/close and can be made to answer or not. */ +class FakeClient { + connected = false + disconnectCount = 0 + connectCount = 0 + constructor( + private readonly behaviour: { + connectFails?: boolean + answers?: boolean + } = {}, + ) {} + + connect = async (): Promise => { + this.connectCount += 1 + if (this.behaviour.connectFails) throw new Error('cannot open') + this.connected = true + } + + disconnect = (): void => { + this.disconnectCount += 1 + this.connected = false + } + + answers(): boolean { + return this.behaviour.answers !== false + } +} + +const asTransport = (client: FakeClient): DeviceModbusTransport => client as unknown as DeviceModbusTransport + +interface Harness { + manager: DeviceSessionManager + statuses: DeviceLinkStatus[] + /** Serial ports the OS currently reports. Mutate to pull or replug a cable. */ + ports: Set + clients: FakeClient[] + /** Overridable per test. */ + verifyResult: { value: boolean } +} + +function harness(overrides: Partial = {}): Harness { + const statuses: DeviceLinkStatus[] = [] + const ports = new Set(['/dev/ttyUSB0']) + const clients: FakeClient[] = [] + const verifyResult = { value: true } + + const hooks: DeviceLinkHooks = { + verify: async () => verifyResult.value, + probe: async (client) => (client as unknown as FakeClient).answers(), + serialPortPresent: async (port) => ports.has(port), + emit: (status) => statuses.push(status), + ...overrides, + } + + const manager = new DeviceSessionManager(hooks, { + pollIntervalMs: 10_000, + failuresBeforeRecovery: 2, + maxRecoveryAttempts: 2, + }) + return { manager, statuses, ports, clients, verifyResult } +} + +/** Candidate factory that hands out the clients a test prepared, in order. */ +function candidate( + transport: 'rtu' | 'tcp', + descriptor: string, + queue: FakeClient[], + registry: FakeClient[], +): DeviceLinkCandidate { + return { + transport, + descriptor, + create: () => { + const client = queue.shift() ?? new FakeClient() + registry.push(client) + return asTransport(client) + }, + } +} + +afterEach(() => { + jest.useRealTimers() +}) + +describe('DeviceSessionManager', () => { + describe('opening', () => { + it('takes the first candidate that works', async () => { + const h = harness() + const tcp = new FakeClient() + const serial = new FakeClient() + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('tcp') + // The serial fallback must not have been touched at all. + expect(serial.connectCount).toBe(0) + expect(h.manager.getLink()).toEqual({ transport: 'tcp', descriptor: '192.168.0.50' }) + h.manager.close() + }) + + it('falls back to serial when Modbus TCP cannot connect', async () => { + const h = harness() + const tcp = new FakeClient({ connectFails: true }) + const serial = new FakeClient() + + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('rtu') + h.manager.close() + }) + + it('falls back when Modbus TCP opens but nothing answers', async () => { + // A socket that connects proves a host, not a PLC. This is the case that + // makes "prefer TCP" safe: an IP that belongs to something else, or a stale + // DHCP address, must not strand the user on a dead link. + const h = harness({ verify: async (client) => (client as unknown as FakeClient).answers() }) + const tcp = new FakeClient({ answers: false }) + const serial = new FakeClient() + + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('rtu') + // The rejected candidate is closed, not leaked. + expect(tcp.disconnectCount).toBeGreaterThan(0) + h.manager.close() + }) + + it('tells verify whether alternatives remain, so patience is spent last', async () => { + // Measured on a real board: ruling out one Modbus TCP address took 32.5s, + // because the id read is retried for a device that might still be booting. + // That patience belongs to the LAST candidate — with alternatives waiting, a + // stale address must not delay the cable that would have worked. + const seen: Array<{ descriptor: string; isLastCandidate: boolean }> = [] + const h = harness({ + verify: async (_client, candidate, context) => { + seen.push({ descriptor: candidate.descriptor, isLastCandidate: context.isLastCandidate }) + return candidate.transport === 'rtu' + }, + }) + + await h.manager.open([ + candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients), + candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients), + ]) + + expect(seen).toEqual([ + { descriptor: '192.168.0.50', isLastCandidate: false }, + { descriptor: '/dev/ttyUSB0', isLastCandidate: true }, + ]) + h.manager.close() + }) + + it('treats a sole candidate as the last one', async () => { + const seen: boolean[] = [] + const h = harness({ + verify: async (_client, _candidate, context) => { + seen.push(context.isLastCandidate) + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients)]) + + expect(seen).toEqual([true]) + h.manager.close() + }) + + it('fails when no candidate works, reporting each attempt', async () => { + const h = harness() + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [new FakeClient({ connectFails: true })], h.clients), + candidate('rtu', '/dev/ttyUSB0', [new FakeClient({ connectFails: true })], h.clients), + ]) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.attempts).toHaveLength(2) + expect(result.attempts[0]).toMatchObject({ transport: 'tcp', descriptor: '192.168.0.50' }) + expect(result.attempts[1]).toMatchObject({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + } + // Nothing is held, and the renderer is told — claiming "connected" without + // a connection is what made later requests time out mysteriously. + expect(h.manager.isConnected()).toBe(false) + expect(h.statuses.at(-1)).toEqual({ status: 'disconnected' }) + }) + + it('skips a serial candidate whose port is not enumerated', async () => { + const h = harness() + h.ports.clear() + const serial = new FakeClient() + + const result = await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + + expect(result.ok).toBe(false) + // Not even opened: no connect timeout was waited out. + expect(serial.connectCount).toBe(0) + if (!result.ok) expect(result.attempts[0].error).toContain('not available') + }) + + it('supersedes a previously held link', async () => { + const h = harness() + const first = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [first], h.clients)]) + + const second = new FakeClient() + await h.manager.open([candidate('tcp', '192.168.0.50', [second], h.clients)]) + + expect(first.disconnectCount).toBe(1) + expect(h.manager.getLink()).toEqual({ transport: 'tcp', descriptor: '192.168.0.50' }) + h.manager.close() + }) + }) + + describe('one owner for every command', () => { + it('hands the same client to every caller', async () => { + // The whole point: the debugger, run/stop and the poll must share this, not + // open their own. A second socket to an Arduino Modbus TCP server is never + // answered, which is how a stop command died with a bare timeout. + const h = harness() + const tcp = new FakeClient() + await h.manager.open([candidate('tcp', '192.168.0.50', [tcp], h.clients)]) + + expect(h.manager.getClient()).toBe(asTransport(tcp)) + expect(h.manager.getClient()).toBe(h.manager.getClient()) + expect(tcp.connectCount).toBe(1) + h.manager.close() + }) + + it('reports no client while recovering, instead of a dead one', async () => { + const h = harness() + const live = new FakeClient({ answers: false }) + await h.manager.open([candidate('tcp', '192.168.0.50', [live], h.clients)]) + + await h.manager.tick() + await h.manager.tick() + + expect(h.manager.isRecovering()).toBe(true) + expect(h.manager.getClient()).toBeNull() + h.manager.close() + }) + }) + + describe('a pulled serial cable', () => { + it('fails immediately on the first tick, without retrying', async () => { + const h = harness() + const serial = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + h.statuses.length = 0 + + h.ports.clear() // cable pulled + await h.manager.tick() + + expect(h.manager.isConnected()).toBe(false) + expect(h.manager.isRecovering()).toBe(false) + expect(h.statuses).toEqual([ + { status: 'error', transport: 'rtu', descriptor: '/dev/ttyUSB0', reason: 'lost' }, + ]) + }) + }) + + describe('a device that stops answering', () => { + it('recovers on its own when it comes back', async () => { + const h = harness() + const dying = new FakeClient({ answers: false }) + const revived = new FakeClient() + const cand = { + transport: 'tcp' as const, + descriptor: '192.168.0.50', + create: jest + .fn() + .mockImplementationOnce(() => asTransport(dying)) + .mockImplementation(() => asTransport(revived)), + } + + await h.manager.open([cand]) + h.statuses.length = 0 + + await h.manager.tick() // one silent poll: tolerated + expect(h.manager.isRecovering()).toBe(false) + await h.manager.tick() // second: enter recovery + expect(h.statuses).toEqual([{ status: 'connecting', transport: 'tcp', descriptor: '192.168.0.50' }]) + expect(dying.disconnectCount).toBe(1) + + await h.manager.tick() // reopen attempt succeeds + expect(h.manager.isRecovering()).toBe(false) + expect(h.manager.getClient()).toBe(asTransport(revived)) + expect(h.statuses.at(-1)).toEqual({ + status: 'connected', + transport: 'tcp', + // Shared session: one medium serves both roles, so both are reported as it. + debugTransport: 'tcp', + descriptor: '192.168.0.50', + }) + h.manager.close() + }) + + it('gives up after the retry budget and reports the link lost', async () => { + const h = harness() + const cand = { + transport: 'tcp' as const, + descriptor: '192.168.0.50', + create: () => asTransport(new FakeClient({ answers: false })), + } + await h.manager.open([cand]) + h.statuses.length = 0 + + await h.manager.tick() + await h.manager.tick() // enter recovery + await h.manager.tick() // attempt 1 + expect(h.manager.isRecovering()).toBe(true) + await h.manager.tick() // attempt 2 -> give up + + expect(h.manager.isConnected()).toBe(false) + expect(h.statuses.at(-1)).toEqual({ + status: 'error', + transport: 'tcp', + descriptor: '192.168.0.50', + reason: 'lost', + }) + }) + + it('can come back on the OTHER transport', async () => { + // Seamless across transports: the link was opened from a candidate list, so + // recovery tries the whole list. An ethernet link that drops while the USB + // cable is plugged in comes back over serial. + const h = harness() + const tcp = new FakeClient({ answers: false }) + const serial = new FakeClient() + const candidates = [ + { transport: 'tcp' as const, descriptor: '192.168.0.50', create: () => asTransport(tcp) }, + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ] + + await h.manager.open(candidates) + await h.manager.tick() + await h.manager.tick() // enter recovery + await h.manager.tick() // reopen: tcp still silent, serial answers + + expect(h.manager.getLink()).toEqual({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + h.manager.close() + }) + + it('treats a throwing probe as unresponsive rather than crashing the tick', async () => { + const h = harness({ + probe: async () => { + throw new Error('read timeout') + }, + }) + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + await expect(h.manager.tick()).resolves.toBeUndefined() + await h.manager.tick() + expect(h.manager.isRecovering()).toBe(true) + h.manager.close() + }) + }) + + describe('a REST-controlled session (Runtime v3/v4)', () => { + it('counts as connected without holding anything open', () => { + // REST is connectionless: there is no socket to hold, poll or recover, so the + // session records the address and routes control operations to it. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'websocket 10.0.0.5', create: () => asTransport(new FakeClient()) }, + }) + + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getRestAddress()).toBe('10.0.0.5') + expect(h.manager.getClient()).toBeNull() // nothing Modbus to hold + expect(h.statuses.at(-1)).toMatchObject({ status: 'connected', descriptor: '10.0.0.5' }) + h.manager.close() + }) + + it('publishes the DEBUG medium, which is not the control one', async () => { + // The debug poll sizes its batches to the frame budget: a WebSocket takes 500 + // variables per round trip, Modbus TCP 60, RTU 19. A v4 session is controlled + // over REST — no medium there at all — so publishing only the control medium + // left the poller with nothing and it silently used TCP-sized batches. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'websocket 10.0.0.5', create: () => asTransport(new FakeClient()) }, + }) + + expect(h.statuses.at(-1)).toEqual({ + status: 'connected', + debugTransport: 'websocket', + descriptor: '10.0.0.5', + }) + h.manager.close() + }) + + it('leaves its debug channel shut until something asks', async () => { + // Logging in to read logs or start the PLC must not open a debug channel — + // for v3 that would be a second Modbus connection to the same box. + const h = harness() + const debug = new FakeClient() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'tcp', descriptor: 'modbus-tcp 10.0.0.5:502', create: () => asTransport(debug) }, + }) + + expect(h.manager.isDebugShared()).toBe(false) + expect(debug.connectCount).toBe(0) + + await h.manager.acquireDebugChannel('debug session') + expect(debug.connectCount).toBe(1) + + h.manager.releaseDebugChannel('debug session') + expect(debug.disconnectCount).toBe(1) + h.manager.close() + }) + + it('forgets the session on close', () => { + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'x', create: () => asTransport(new FakeClient()) }, + }) + + h.manager.close() + + expect(h.manager.isConnected()).toBe(false) + expect(h.manager.getRestAddress()).toBeNull() + expect(h.statuses.at(-1)).toEqual({ status: 'disconnected' }) + }) + + it('is superseded by a device connection', async () => { + // One target at a time: connecting a device replaces a runtime session rather + // than leaving two sessions claiming to be current. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'x', create: () => asTransport(new FakeClient()) }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients)]) + + expect(h.manager.getRestAddress()).toBeNull() + expect(h.manager.getLink()).toEqual({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + h.manager.close() + }) + }) + + describe('the debug channel', () => { + it('IS the control channel when one medium serves both', async () => { + // A baremetal board answers control and debug over one connection. Opening a + // second client to it is what an Arduino Modbus TCP server never answers, and + // what the OS refuses on a serial port. + const h = harness() + const only = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [only], h.clients)]) + + expect(h.manager.isDebugShared()).toBe(true) + const acquired = await h.manager.acquireDebugChannel('debug session') + expect('client' in acquired && acquired.client).toBe(asTransport(only)) + expect(only.connectCount).toBe(1) + h.manager.close() + }) + + it('releasing a shared channel never closes the connection', async () => { + // Stopping the debugger must not take the connection run/stop and the status + // poll are using. + const h = harness() + const only = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [only], h.clients)]) + await h.manager.acquireDebugChannel('debug session') + + h.manager.releaseDebugChannel('debug session') + + expect(only.disconnectCount).toBe(0) + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getDebugClient()).toBe(asTransport(only)) + h.manager.close() + }) + + it('opens a channel of its own when the debug medium differs', async () => { + // A Runtime v3/v4 shape: control is elsewhere, debug is its own channel, and + // it stays shut until something asks for it. + const h = harness() + const control = new FakeClient() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [control], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + + expect(h.manager.isDebugShared()).toBe(false) + expect(h.manager.getDebugClient()).toBeNull() + expect(debug.connectCount).toBe(0) + + const acquired = await h.manager.acquireDebugChannel('debug session') + expect('client' in acquired).toBe(true) + expect(debug.connectCount).toBe(1) + h.manager.close() + }) + + it('closes its own channel only when the last holder lets go', async () => { + const h = harness() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + + await h.manager.acquireDebugChannel('debug session') + await h.manager.acquireDebugChannel('license check') + expect(debug.connectCount).toBe(1) // reused, not reopened + + // A license check finishing must not close the channel a live debug session + // is still reading through. + h.manager.releaseDebugChannel('license check') + expect(debug.disconnectCount).toBe(0) + expect(h.manager.getDebugClient()).not.toBeNull() + + h.manager.releaseDebugChannel('debug session') + expect(debug.disconnectCount).toBe(1) + expect(h.manager.getDebugClient()).toBeNull() + h.manager.close() + }) + + it('leaves control connected when its own channel will not open', async () => { + // Independent channels: port 502 firewalled is a debugging problem, not a + // reason to drop a working control connection. + const h = harness() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: candidate('tcp', '10.0.0.5:502', [new FakeClient({ connectFails: true })], h.clients), + }) + + const acquired = await h.manager.acquireDebugChannel('debug session') + + expect('error' in acquired).toBe(true) + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getClient()).not.toBeNull() + h.manager.close() + }) + + it('closes its own channel when the session ends', async () => { + const h = harness() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + await h.manager.acquireDebugChannel('debug session') + + h.manager.close() + + expect(debug.disconnectCount).toBe(1) + expect(h.manager.getDebugClient()).toBeNull() + }) + + it('refuses to acquire when nothing is connected', async () => { + const h = harness() + expect(await h.manager.acquireDebugChannel('debug session')).toEqual({ error: 'Not connected' }) + }) + }) + + describe('upload handoff', () => { + it('releases a serial link that holds the port being flashed', async () => { + const h = harness() + const serial = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(true) + expect(h.manager.isConnected()).toBe(false) + expect(serial.disconnectCount).toBe(1) + }) + + it('keeps a TCP link across an upload', async () => { + // Flashing over USB does not disturb an ethernet link, so debugging and + // run/stop keep working through an upload. + const h = harness() + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(false) + expect(h.manager.isConnected()).toBe(true) + h.manager.close() + }) + + it('leaves a serial link on a different port alone', async () => { + const h = harness() + h.ports.add('/dev/ttyUSB1') + await h.manager.open([candidate('rtu', '/dev/ttyUSB1', [new FakeClient()], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(false) + expect(h.manager.isConnected()).toBe(true) + h.manager.close() + }) + }) + + describe('polling', () => { + it('stops polling once the link is closed', async () => { + jest.useFakeTimers() + const probe = jest.fn().mockResolvedValue(true) + const h = harness({ probe }) + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + jest.advanceTimersByTime(30_000) + const callsWhileOpen = probe.mock.calls.length + expect(callsWhileOpen).toBeGreaterThan(0) + + h.manager.close() + jest.advanceTimersByTime(30_000) + expect(probe.mock.calls.length).toBe(callsWhileOpen) + }) + }) +}) diff --git a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts index be8c99782..5c28e34a6 100644 --- a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -22,28 +22,24 @@ describe('toCalloutPath', () => { }) describe('mergeSerialPortList', () => { - it('labels a port with the arduino-cli board name when identified', () => { + it('reports both descriptors when both scans knew one', () => { + // The merge no longer picks a winner: it reports what each scan found and + // lets `serialPortDisplay` apply the precedence. That split is why the + // renderer can no longer mistake one shape for another. const boards = boardMap([['/dev/cu.usbmodem1', 'Arduino Uno']]) const manufacturers = boardMap([['/dev/cu.usbmodem1', 'Arduino LLC']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.usbmodem1 (Arduino Uno)', address: '/dev/cu.usbmodem1' }, + { address: '/dev/cu.usbmodem1', boardName: 'Arduino Uno', manufacturer: 'Arduino LLC' }, ]) }) - it('prefers the board name over the manufacturer when both are present', () => { - const boards = boardMap([['COM1', 'Opta']]) - const manufacturers = boardMap([['COM1', 'Arduino']]) - - expect(mergeSerialPortList(boards, manufacturers)[0].name).toBe('COM1 (Opta)') - }) - it('falls back to the manufacturer when the board is detected but not identified', () => { const boards = boardMap([['COM6', undefined]]) const manufacturers = boardMap([['COM6', 'com0com - serial port emulator']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: 'COM6 (com0com - serial port emulator)', address: 'COM6' }, + { address: 'COM6', manufacturer: 'com0com - serial port emulator' }, ]) }) @@ -51,14 +47,14 @@ describe('mergeSerialPortList', () => { const boards = boardMap([]) const manufacturers = boardMap([['/dev/ttyUSB0', undefined]]) - expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }]) + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ address: '/dev/ttyUSB0' }]) }) it('treats an empty-string descriptor as absent', () => { const boards = boardMap([['COM1', '']]) const manufacturers = boardMap([['COM1', '']]) - expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: 'COM1', address: 'COM1' }]) + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ address: 'COM1' }]) }) it('unions both scans, keeps serialport ordering, and dedupes by path', () => { @@ -73,9 +69,9 @@ describe('mergeSerialPortList', () => { ]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: 'COM3 (FTDI)', address: 'COM3' }, - { name: 'COM4 (Arduino Mega)', address: 'COM4' }, - { name: 'COM9 (Arduino Nano)', address: 'COM9' }, + { address: 'COM3', manufacturer: 'FTDI' }, + { address: 'COM4', boardName: 'Arduino Mega' }, + { address: 'COM9', boardName: 'Arduino Nano' }, ]) }) @@ -89,7 +85,7 @@ describe('mergeSerialPortList', () => { const boards = boardMap([['/dev/cu.usbmodem11301', 'Opta']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + { address: '/dev/cu.usbmodem11301', boardName: 'Opta', manufacturer: 'Arduino' }, ]) }) @@ -97,7 +93,7 @@ describe('mergeSerialPortList', () => { const manufacturers = boardMap([['/dev/tty.usbserial-99', 'FTDI']]) expect(mergeSerialPortList(boardMap([]), manufacturers)).toEqual([ - { name: '/dev/cu.usbserial-99 (FTDI)', address: '/dev/cu.usbserial-99' }, + { address: '/dev/cu.usbserial-99', manufacturer: 'FTDI' }, ]) }) @@ -117,10 +113,10 @@ describe('mergeSerialPortList', () => { ]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.debug-console', address: '/dev/cu.debug-console' }, - { name: '/dev/cu.Bluetooth-Incoming-Port', address: '/dev/cu.Bluetooth-Incoming-Port' }, - { name: '/dev/cu.usbserial-1140 (Prolific Technology Inc.)', address: '/dev/cu.usbserial-1140' }, - { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + { address: '/dev/cu.debug-console' }, + { address: '/dev/cu.Bluetooth-Incoming-Port' }, + { address: '/dev/cu.usbserial-1140', manufacturer: 'Prolific Technology Inc.' }, + { address: '/dev/cu.usbmodem11301', boardName: 'Opta', manufacturer: 'Arduino' }, ]) }) @@ -132,8 +128,8 @@ describe('mergeSerialPortList', () => { const boards = boardMap([['/dev/ttyACM0', 'Arduino Uno']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/ttyUSB0 (FTDI)', address: '/dev/ttyUSB0' }, - { name: '/dev/ttyACM0 (Arduino Uno)', address: '/dev/ttyACM0' }, + { address: '/dev/ttyUSB0', manufacturer: 'FTDI' }, + { address: '/dev/ttyACM0', boardName: 'Arduino Uno' }, ]) }) }) diff --git a/src/backend/editor/hardware/device-link-policy.ts b/src/backend/editor/hardware/device-link-policy.ts new file mode 100644 index 000000000..d3cde5d7a --- /dev/null +++ b/src/backend/editor/hardware/device-link-policy.ts @@ -0,0 +1,125 @@ +/** + * When is a held device link down, coming back, or gone for good? + * + * The I/O around the link (open a port or socket, read the status frame, close a + * dead handle) lives in `DeviceSessionManager`. What lives HERE is only the + * counting, because that is where the off-by-ones hide and it is the one part + * that can be tested without a cable to pull. + * + * Two states: + * + * healthy - polling a live client. Consecutive silent polls accumulate; + * `failuresBeforeRecovery` of them enter recovery. Any answer + * resets the count, so one dropped frame is not a dropped link. + * recovering - the link is down and reopens are attempted, one per tick. A + * reopen that answers restores the link; `maxRecoveryAttempts` + * failures declare it lost. + * + * Two things set the pace, and they pull in opposite directions: + * + * - Failing fast is good. A link that is definitely gone should say so at once, + * not after half a minute of pointless retries. + * - Reopening is NOT free. Opening a serial port asserts DTR, which resets an + * AVR board — so a trigger-happy reconnect would restart the user's PLC + * program over a single dropped frame. (Native-USB parts like the SAMD in a + * P1AM do not reset, but the policy cannot know which board it is talking to.) + * + * Hence: a `gone` verdict — the serial port is no longer enumerated, so there is + * nothing to reset and nothing to wait for — fails IMMEDIATELY, bypassing the + * budget entirely. An `unresponsive` verdict, which may be noise, spends the + * budget first and only then reopens. + */ + +/** What a single probe of the held link concluded. */ +export type LinkProbeVerdict = + /** Answered. */ + | 'alive' + /** Open but silent: timed out, bad reply, or an unexplained error. */ + | 'unresponsive' + /** The endpoint itself is no longer there (serial port vanished from the OS). */ + | 'gone' + +/** What the caller should do after reporting a probe. */ +export type ProbeDecision = + /** Healthy, or not yet past the failure budget — keep polling. */ + | 'continue' + /** Link is down: drop the dead client, keep the link, start reopening. */ + | 'enter-recovery' + /** Endpoint is gone: tear down and tell the user now. No retries. */ + | 'fail-now' + +/** What the caller should do after reporting a reopen attempt. */ +export type ReopenDecision = + /** Not back yet, attempts remain — try again next tick. */ + | 'retry' + /** Back: adopt the fresh client and report connected. */ + | 'recovered' + /** Out of attempts: tear down and tell the user. */ + | 'give-up' + +export class DeviceLinkPolicy { + private consecutiveFailures = 0 + private recoveryAttempts = 0 + private inRecovery = false + + constructor( + private readonly failuresBeforeRecovery: number, + private readonly maxRecoveryAttempts: number, + ) {} + + /** True while reopens are being attempted rather than the client polled. */ + get recovering(): boolean { + return this.inRecovery + } + + /** Attempts made in the current recovery window (0 when healthy). */ + get attempts(): number { + return this.recoveryAttempts + } + + /** Back to a freshly connected, healthy link. */ + reset(): void { + this.consecutiveFailures = 0 + this.recoveryAttempts = 0 + this.inRecovery = false + } + + /** Report what this tick's probe of the held client concluded. */ + onProbeResult(verdict: LinkProbeVerdict): ProbeDecision { + if (verdict === 'alive') { + this.consecutiveFailures = 0 + return 'continue' + } + if (verdict === 'gone') { + // Nothing to retry against and nothing to reset: the endpoint is not there. + this.reset() + return 'fail-now' + } + this.consecutiveFailures += 1 + if (this.consecutiveFailures < this.failuresBeforeRecovery) return 'continue' + + this.inRecovery = true + this.recoveryAttempts = 0 + this.consecutiveFailures = 0 + return 'enter-recovery' + } + + /** + * Report whether this tick's reopen produced a link that answers. Counts the + * attempt, so a caller that could not even build a client (no candidates left, + * port gone) must still report `false` — otherwise recovery would retry forever + * and the user would never be told. + */ + onReopenResult(recovered: boolean): ReopenDecision { + this.recoveryAttempts += 1 + if (recovered) { + this.reset() + return 'recovered' + } + if (this.recoveryAttempts >= this.maxRecoveryAttempts) { + this.reset() + return 'give-up' + } + return 'retry' + } +} diff --git a/src/backend/editor/hardware/device-probe.ts b/src/backend/editor/hardware/device-probe.ts new file mode 100644 index 000000000..295658f5d --- /dev/null +++ b/src/backend/editor/hardware/device-probe.ts @@ -0,0 +1,114 @@ +/** + * Connect-time classification of a device link (D72), over an ALREADY-CONNECTED + * `DeviceChannelTransport`: it neither connects nor disconnects — the caller + * holds the client open for the live link, so classification happens over a + * SINGLE port open. + * + * Pure orchestration over the transport, so it is unit-testable with mocks. + * Never throws — failures resolve to a status. + */ +import { getErrorMessage } from '../../../frontend/utils/get-error-message' +import type { DebugBoardIdResult } from '../../shared/debug/types' + +/** Just enough of a channel to open it. */ +type Connectable = { connect(): Promise } + +/** + * Just enough of a channel to classify it. Narrower than + * `DeviceChannelTransport`, where `getBoardId` is optional: a channel that + * cannot answer the board-id read is not one this module can classify. + */ +type BoardIdReadable = { getBoardId(): Promise } + +/** Retry budget for a bounded connect/probe loop. */ +export interface ProbeBudget { + attempts: number + backoffMs: number +} + +/** + * How patiently to wait for a board to answer the id read (0x48). + * + * The generous default exists for ONE situation: a board that has just been + * flashed and is still coming up. Six attempts at a 5s request timeout is ~32s of + * patience, which is right when this is the only endpoint there is and the device + * is expected to appear. + * + * It is wrong when the caller is CHOOSING between endpoints: 32s spent ruling out + * a Modbus TCP address the user is not even using delays the serial connection + * that would have worked. Such callers pass a short budget and move on. + */ +export const PATIENT_BOARD_ID_PROBE: ProbeBudget = { attempts: 6, backoffMs: 500 } +export const QUICK_BOARD_ID_PROBE: ProbeBudget = { attempts: 2, backoffMs: 300 } + +/** + * Connect with a bounded retry/backoff loop. A device flashed over arduino-cli + * serial reboots as the programmer releases the port, so the first connect right + * after an upload frequently races the reboot; retry, rethrowing the last error + * only once every attempt is exhausted. + */ +export async function connectWithRetries(client: Connectable, { attempts, backoffMs }: ProbeBudget): Promise { + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + await client.connect() + return + } catch (error) { + lastError = error + if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, backoffMs)) + } + } + throw lastError +} + +/** + * Read the board id (FC 0x48) with a bounded retry/backoff loop -- a readiness + * probe for the firmware itself (the serial open auto-resets ESP8266/AVR boards). + * A non-empty board id means a firmware answered. + */ +export async function readBoardIdWithRetries( + client: BoardIdReadable, + { attempts, backoffMs }: ProbeBudget, +): Promise<{ success: boolean; boardId?: Uint8Array }> { + let last: { success: boolean; boardId?: Uint8Array } = { success: false } + for (let attempt = 0; attempt < attempts; attempt++) { + const result = await client.getBoardId() + last = { success: result.success, boardId: result.boardId } + if (last.success && !!last.boardId && last.boardId.length > 0) return last + if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, backoffMs)) + } + return last +} + +/** How a freshly-opened channel classified. */ +export type DeviceProbeStatus = 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + +export interface DeviceProbeOutcome { + status: DeviceProbeStatus + error?: string +} + +/** + * Classify an already-connected candidate: did an OpenPLC firmware answer the + * debug protocol on it? + * + * Only the board-id read decides. A channel that opens but answers nothing — + * a blank board, or an IP that belongs to something else entirely — classifies + * as `no-firmware`, so the caller can fall through to the next candidate rather + * than keeping a link that cannot serve a single command. + */ +export async function classifyDeviceLink( + client: BoardIdReadable, + opts: { boardIdProbe?: ProbeBudget } = {}, +): Promise { + try { + const probe = await readBoardIdWithRetries(client, opts.boardIdProbe ?? PATIENT_BOARD_ID_PROBE) + if (!probe.success || !probe.boardId || probe.boardId.length === 0) { + // Channel opened but nothing spoke the debug protocol -> blank/non-OpenPLC. + return { status: 'no-firmware' } + } + return { status: 'connected-with-firmware' } + } catch (error) { + return { status: 'error', error: getErrorMessage(error) } + } +} diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts new file mode 100644 index 000000000..54b0d7133 --- /dev/null +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -0,0 +1,623 @@ +/** + * THE session with a device: what it is reached through, and by whom. + * + * A session has two channel slots — CONTROL (run/stop, status) and DEBUG + * (variables, md5, licensing) — because that is the shape real targets have: + * + * - a baremetal board answers both over ONE Modbus connection, serial or TCP; + * - a Runtime v3/v4 is controlled over REST but debugged over something else + * entirely (Modbus TCP / a WebSocket); + * - the simulator answers both over its in-process virtual serial port. + * + * When both roles share a medium the two slots hold the SAME channel, so nothing + * opens twice and releasing the debug role cannot close the connection out from + * under run/stop. When they differ the debug channel is opened on request and + * closed when the last requester lets go — an independent channel, whose failure + * leaves control untouched. + * + * Whatever the shape, every caller shares what the session holds: the debugger, + * run/stop, the status poll, licensing. + * That single-ownership rule is the point of this module. Before it, three + * places opened their own client for the same device (the debug session, the two + * lazy-reconnect paths, and a transient one per run/stop command), and each had + * its own idea of which transport counted as reusable. A run/stop command with a + * live Modbus TCP session therefore opened a SECOND socket to the board — which + * an Arduino Modbus TCP server, serving one client at a time, never answered, so + * the command failed with a bare timeout while a perfectly good connection sat + * idle. + * + * Transport is a detail here, not a branch. Both Modbus clients implement + * `DeviceModbusTransport`, so this module never asks which one it holds except to + * describe it to the user and to know whether a vanished serial port applies. + * + * What the manager does NOT decide: + * - which candidates to try, or in what order -> the caller resolves those + * from the board's debug spec (Modbus TCP first when the project enables it, + * serial otherwise), so this works for every baremetal target rather than + * any particular board; + * - what "this is really the device" means -> `hooks.verify`, which the + * main process implements as its existing classify + license recover; + * - the counting for down / back / lost -> `DeviceLinkPolicy`. + */ +import type { DeviceDebugChannel, DeviceModbusTransport } from '../../shared/debug/types' +import { DeviceLinkPolicy } from './device-link-policy' + +export type DeviceLinkTransport = 'rtu' | 'tcp' | 'simulator' + +/** + * How to open a DEBUG channel that is not the control channel. Simpler than a + * control candidate: there is nothing to choose between and nothing to classify — + * the control side already established what this target is. + */ +export interface DeviceDebugCandidate { + /** + * Medium this channel rides. Published with the session status because the debug + * poll sizes its batches to the frame budget (a WebSocket swallows 500 variables + * per round trip, Modbus TCP 60, RTU 19), and a poller that has to GUESS the + * medium either wastes round trips or overruns a frame. + */ + transport: DeviceLinkTransport | 'websocket' + descriptor: string + create: () => DeviceDebugChannel +} + +/** One way to reach the device, ready to be tried. */ +export interface DeviceLinkCandidate { + transport: DeviceLinkTransport + /** What the user calls this endpoint: "/dev/cu.usbmodem11101", "192.168.0.50". */ + descriptor: string + /** Build an unconnected client for this candidate. */ + create: () => DeviceModbusTransport +} + +/** Live link state, as pushed to the renderer. */ +export interface DeviceLinkStatus { + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** + * The CONTROL channel's medium. Absent for a REST-controlled session (v3/v4): + * REST holds no connection, so there is no medium to report or lose. + */ + transport?: DeviceLinkTransport + /** + * The DEBUG channel's medium — the same as `transport` when one channel serves + * both roles, and `websocket` (v4) or `tcp` (v3) when it does not. Reported + * separately because these are genuinely two facts: the control medium decides + * what "the connection dropped" means, the debug medium decides the poll's frame + * budget. + */ + debugTransport?: DeviceLinkTransport | 'websocket' + descriptor?: string + /** + * Set only when a link that WAS up died and could not be recovered. The one + * status the user must be told about; every other 'error' came straight out of + * something they just clicked and already has its own dialog. + */ + reason?: 'lost' +} + +export interface DeviceLinkOpenSuccess { + ok: true + transport: DeviceLinkTransport + descriptor: string + client: DeviceModbusTransport +} + +export interface DeviceLinkOpenFailure { + ok: false + /** Every candidate that was tried, with why it did not work. */ + attempts: Array<{ transport: DeviceLinkTransport; descriptor: string; error: string }> +} + +export type DeviceLinkOpenResult = DeviceLinkOpenSuccess | DeviceLinkOpenFailure + +export interface DeviceLinkHooks { + /** + * Is this freshly opened client really a device we can work with? Decides + * whether to keep a candidate or move on to the next one, so a Modbus TCP + * socket that opens but answers nothing correctly falls back to serial. + * + * The main process implements this as its classify + license recover, which is + * why it runs on open only — see `probe` for the per-tick check. + */ + verify: ( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ) => Promise + /** + * Cheap liveness read on the held client, also used to confirm a reopen. Kept + * separate from `verify` so recovery does not re-run licensing every couple of + * seconds for as long as a cable is out. + */ + probe: (client: DeviceModbusTransport) => Promise + /** Is this serial port still enumerated by the OS? */ + serialPortPresent: (port: string) => Promise + /** Report a link state change to the renderer. */ + emit: (status: DeviceLinkStatus) => void + /** + * Diagnostic trace of every decision this manager makes: which candidate was + * tried, how long its connect took, why it was kept or rejected, what each poll + * concluded. Optional, but in practice always supplied — a connection flow that + * spans two transports and a remote board cannot be diagnosed by watching the UI. + */ + log?: (message: string) => void +} + +export interface DeviceLinkTimings { + pollIntervalMs: number + failuresBeforeRecovery: number + maxRecoveryAttempts: number +} + +/** Fail fast, but not so fast that noise reopens a port. See DeviceLinkPolicy. */ +export const DEFAULT_DEVICE_LINK_TIMINGS: DeviceLinkTimings = { + pollIntervalMs: 2500, + failuresBeforeRecovery: 2, + maxRecoveryAttempts: 2, +} + +export class DeviceSessionManager { + /** The control channel's client, and the debug channel's too when shared. */ + private client: DeviceModbusTransport | null = null + private current: DeviceLinkCandidate | null = null + /** + * Debug channel, when it is NOT the control channel: its client (null until + * something asks for it) and how to open it. + */ + private debugClientHeld: DeviceDebugChannel | null = null + private debugCandidate: DeviceDebugCandidate | null = null + /** + * Who currently wants the debug channel, by reason. A set rather than a counter + * so the trace can say who is holding it, and so a double release from one + * caller cannot close a channel another still needs. + */ + private readonly debugHolders = new Set() + /** + * Runtime targets (v3/v4) are CONTROLLED over REST, which is connectionless: + * there is no socket to hold, poll or recover, so the session records the address + * and routes control operations to the HTTP client instead of holding a channel. + * That is why this slot is an address rather than a client — and why polling and + * recovery below apply only to a Modbus control channel. + */ + private restControl: { address: string } | null = null + /** The full list the link was opened from, so recovery can try them all again. */ + private candidates: DeviceLinkCandidate[] = [] + private readonly policy: DeviceLinkPolicy + private timer: ReturnType | null = null + private tickInFlight = false + + constructor( + private readonly hooks: DeviceLinkHooks, + private readonly timings: DeviceLinkTimings = DEFAULT_DEVICE_LINK_TIMINGS, + ) { + this.policy = new DeviceLinkPolicy(timings.failuresBeforeRecovery, timings.maxRecoveryAttempts) + } + + private trace(message: string): void { + this.hooks.log?.(message) + } + + /** + * The CONTROL channel's client, or null when nothing is connected (including + * mid-recovery). Run/stop and the status poll go here. + */ + getClient(): DeviceModbusTransport | null { + return this.client + } + + /** + * The DEBUG channel's client, or null when it is not open. + * + * For a shared session this IS the control client, so it needs no acquiring and + * cannot be closed independently. For a session whose debug medium differs, it + * is null until someone calls `acquireDebugChannel`. + */ + getDebugClient(): DeviceDebugChannel | null { + return this.debugCandidate ? this.debugClientHeld : this.client + } + + /** True when one medium serves both roles, so the slots hold the same channel. */ + isDebugShared(): boolean { + return this.debugCandidate === null + } + + /** + * Open the debug channel if it isn't already, and record `reason` as a holder. + * + * Independent of control on purpose: a debug channel that will not open is + * reported to whoever asked, and the control connection carries on. For a shared + * session there is nothing to open — the answer is the control channel, and the + * session having been established is the only precondition. + */ + async acquireDebugChannel(reason: string): Promise<{ client: DeviceDebugChannel } | { error: string }> { + if (this.debugCandidate === null) { + if (!this.client) return { error: 'Not connected' } + // (A REST session always has a debug candidate, so it never lands here.) + this.debugHolders.add(reason) + return { client: this.client } + } + + if (this.debugClientHeld) { + this.debugHolders.add(reason) + return { client: this.debugClientHeld } + } + + this.trace(`debug channel: opening ${this.debugCandidate.descriptor} for ${reason}`) + let client: DeviceDebugChannel + try { + client = this.debugCandidate.create() + await client.connect() + } catch (error) { + this.trace(`debug channel: could not open — ${describeError(error)} (control connection unaffected)`) + return { error: describeError(error) } + } + this.debugClientHeld = client + this.debugHolders.add(reason) + return { client } + } + + /** + * Let go of the debug channel. It closes only once nothing holds it AND it is a + * channel of its own — releasing a shared one must never take the connection + * that run/stop and the status poll are using. + */ + releaseDebugChannel(reason: string): void { + this.debugHolders.delete(reason) + if (this.debugHolders.size > 0) return + if (this.debugCandidate === null || !this.debugClientHeld) return + this.trace(`debug channel: closing (last holder ${reason} released)`) + this.debugClientHeld.disconnect() + this.debugClientHeld = null + } + + /** Transport + endpoint of the held link, for messages and handoff decisions. */ + getLink(): { transport: DeviceLinkTransport; descriptor: string } | null { + if (!this.current) return null + return { transport: this.current.transport, descriptor: this.current.descriptor } + } + + /** True while the link is down and reopens are being attempted. */ + isRecovering(): boolean { + return this.policy.recovering + } + + isConnected(): boolean { + return this.client !== null || this.restControl !== null + } + + /** + * Open the first candidate that works and hold it. + * + * Candidates are tried IN ORDER and the first one to both connect and verify + * wins; a candidate that connects but fails verification is closed before the + * next is tried, so no stray handles are left behind. If none work the attempt + * fails, reporting what was tried — an editor that claimed "connected" without + * a working connection is what made every later request time out mysteriously. + * + * A fresh open supersedes any held link (reconnect, transport change). + */ + async open( + candidates: DeviceLinkCandidate[], + options: { + /** + * How to reach this target for DEBUG when that is a different medium from + * control (Runtime v3/v4). Omit when one medium serves both — the slots then + * share a channel, which is what keeps a debug session from opening a second + * connection to a device that only answers one. + */ + debugChannel?: DeviceDebugCandidate + } = {}, + ): Promise { + this.close({ silent: true }) + this.debugCandidate = options.debugChannel ?? null + + if (candidates.length === 0) { + this.trace('open: refused, no usable candidate was resolved') + return { ok: false, attempts: [] } + } + + this.candidates = candidates + const attempts: DeviceLinkOpenFailure['attempts'] = [] + this.trace( + `open: ${candidates.length} candidate(s) in order: ${candidates + .map((candidate) => `${candidate.transport} ${candidate.descriptor}`) + .join(', ')}`, + ) + + for (const [index, candidate] of candidates.entries()) { + this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) + + const startedAt = Date.now() + const outcome = await this.tryCandidate(candidate, { isLastCandidate: index === candidates.length - 1 }) + const elapsed = Date.now() - startedAt + this.trace( + outcome.ok + ? `open: ${candidate.transport} ${candidate.descriptor} ACCEPTED in ${elapsed}ms` + : `open: ${candidate.transport} ${candidate.descriptor} rejected in ${elapsed}ms — ${outcome.error}`, + ) + if (outcome.ok) { + this.client = outcome.client + this.current = candidate + this.policy.reset() + this.startPolling() + this.hooks.emit({ + status: 'connected', + transport: candidate.transport, + // Shared unless the caller supplied a separate debug channel. + debugTransport: this.debugCandidate?.transport ?? candidate.transport, + descriptor: candidate.descriptor, + }) + return { ok: true, transport: candidate.transport, descriptor: candidate.descriptor, client: outcome.client } + } + attempts.push({ transport: candidate.transport, descriptor: candidate.descriptor, error: outcome.error }) + } + + this.candidates = [] + this.trace('open: FAILED, no candidate answered') + this.hooks.emit({ status: 'disconnected' }) + return { ok: false, attempts } + } + + /** + * Establish a session with a target CONTROLLED over REST (Runtime v3/v4). + * + * Nothing is opened here. REST needs no connection, and the debug channel is + * deliberately left shut until something asks for it — a user who logs in to look + * at logs or start the PLC should not be made to hold a debug channel open, and + * for v3 that channel is a second Modbus connection to the same box. + */ + openRestSession(options: { address: string; debugChannel: DeviceDebugCandidate }): void { + this.close({ silent: true }) + this.restControl = { address: options.address } + this.debugCandidate = options.debugChannel + this.trace(`session: control over REST at ${options.address}, debug via ${options.debugChannel.descriptor}`) + this.hooks.emit({ + status: 'connected', + // No control transport: REST holds nothing. The debug medium is what the + // debugger will actually ride, and what its poll must be sized for. + debugTransport: options.debugChannel.transport, + descriptor: options.address, + }) + } + + /** The REST address when control runs over REST, else null. */ + getRestAddress(): string | null { + return this.restControl?.address ?? null + } + + /** Open + verify a single candidate, leaving nothing open on failure. */ + private async tryCandidate( + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ): Promise<{ ok: true; client: DeviceModbusTransport } | { ok: false; error: string }> { + // A serial candidate whose port is not even enumerated cannot be opened: + // say so instead of waiting out a connect timeout. + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { + this.trace(` ${candidate.descriptor}: serial port is not enumerated, skipping`) + return { ok: false, error: `${candidate.descriptor} is not available` } + } + + let client: DeviceModbusTransport + try { + client = candidate.create() + } catch (error) { + return { ok: false, error: describeError(error) } + } + + const connectStartedAt = Date.now() + try { + await client.connect() + this.trace(` ${candidate.descriptor}: transport opened in ${Date.now() - connectStartedAt}ms`) + } catch (error) { + client.disconnect() + this.trace(` ${candidate.descriptor}: transport would not open after ${Date.now() - connectStartedAt}ms`) + return { ok: false, error: describeError(error) } + } + + // Opening proves an endpoint, not a PLC. A Modbus TCP socket to something + // that is not an OpenPLC target connects instantly and then answers nothing, + // so this is the step that decides whether to keep the candidate. + const verifyStartedAt = Date.now() + try { + if (await this.hooks.verify(client, candidate, context)) { + this.trace(` ${candidate.descriptor}: answered the debug protocol in ${Date.now() - verifyStartedAt}ms`) + return { ok: true, client } + } + client.disconnect() + this.trace( + ` ${candidate.descriptor}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, + ) + return { ok: false, error: 'No OpenPLC firmware answered' } + } catch (error) { + client.disconnect() + this.trace(` ${candidate.descriptor}: verification threw after ${Date.now() - verifyStartedAt}ms`) + return { ok: false, error: describeError(error) } + } + } + + /** + * Close the held link. `silent` skips the renderer notification, for the case + * where a new open is about to report its own state. + */ + close(options: { silent?: boolean } = {}): void { + this.stopPolling() + this.policy.reset() + this.debugHolders.clear() + if (this.debugClientHeld) { + this.debugClientHeld.disconnect() + this.debugClientHeld = null + } + this.debugCandidate = null + const hadRest = this.restControl !== null + this.restControl = null + const had = this.client !== null || hadRest + if (had) this.trace(`close: dropping ${this.current?.transport ?? (hadRest ? 'rest' : '?')} session`) + this.dropClient() + this.current = null + this.candidates = [] + if (had && !options.silent) this.hooks.emit({ status: 'disconnected' }) + } + + /** + * Give up the link if it holds `port` — the handoff before an upload takes the + * same serial port. Returns whether anything was released, so the caller knows + * whether to reconnect afterwards. + * + * A link running over Modbus TCP is untouched: flashing over USB does not + * disturb it, so debugging and run/stop keep working across an upload. + */ + releaseSerialPort(port: string | null | undefined): boolean { + if (!this.current || this.current.transport !== 'rtu') { + this.trace(`release ${String(port)}: nothing to release (held: ${this.current?.transport ?? 'none'})`) + return false + } + if (port !== undefined && port !== null && this.current.descriptor !== String(port)) { + this.trace(`release ${String(port)}: held connection is on ${this.current.descriptor}, leaving it alone`) + return false + } + this.trace(`release ${this.current.descriptor}: handing the port over for an upload`) + this.close() + return true + } + + private dropClient(): void { + this.client?.disconnect() + this.client = null + } + + private startPolling(): void { + this.stopPolling() + this.timer = setInterval(() => { + if (this.tickInFlight) return + this.tickInFlight = true + void this.tick().finally(() => { + this.tickInFlight = false + }) + }, this.timings.pollIntervalMs) + } + + private stopPolling(): void { + if (!this.timer) return + clearInterval(this.timer) + this.timer = null + } + + /** + * One step of the link's lifecycle: probe the held client, or make a single + * reopen attempt while recovering. Public so it can be driven directly in + * tests instead of waiting on a timer. + */ + async tick(): Promise { + if (this.policy.recovering) return this.attemptRecovery() + + const client = this.client + const candidate = this.current + if (!client || !candidate) return + + const verdict = await this.probeVerdict(client, candidate) + const decision = this.policy.onProbeResult(verdict) + if (verdict !== 'alive') { + this.trace(`poll: ${candidate.transport} ${candidate.descriptor} ${verdict} -> ${decision}`) + } + switch (decision) { + case 'enter-recovery': + // Drop the dead handle but KEEP the link: a stale open fd is what makes + // the reopen fail with "cannot lock port", while the candidate list is + // what lets the next ticks bring it back with nothing for the user to do. + this.dropClient() + this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) + return + case 'fail-now': + return this.declareLost(candidate) + default: + return + } + } + + /** Classify one probe of the held client. */ + private async probeVerdict( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + ): Promise<'alive' | 'unresponsive' | 'gone'> { + // Check the endpoint first: a pulled USB cable is not a slow device, and + // treating it as one would spend the whole failure budget waiting for + // timeouts on a port that no longer exists. + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { + return 'gone' + } + try { + return (await this.hooks.probe(client)) ? 'alive' : 'unresponsive' + } catch { + return 'unresponsive' + } + } + + /** + * One reopen attempt while recovering. Tries the SAME candidate list the link + * was opened from, so a device that comes back on either transport is picked + * up — and a serial port that has not reappeared is skipped without cost. + * + * Verification here is the cheap `probe`, not `verify`: the classification and + * license recover from the original open still stand, and re-running them every + * couple of seconds while a cable is out would hammer the licensing backend. + */ + private async attemptRecovery(): Promise { + const previous = this.current + if (!previous) return + + const reopened = await this.reopen() + this.trace( + `recovery: attempt ${this.policy.attempts + 1} ${reopened ? `restored over ${reopened.candidate.transport}` : 'failed'}`, + ) + + switch (this.policy.onReopenResult(reopened !== null)) { + case 'recovered': + this.client = reopened!.client + this.current = reopened!.candidate + this.hooks.emit({ + status: 'connected', + transport: reopened!.candidate.transport, + debugTransport: this.debugCandidate?.transport ?? reopened!.candidate.transport, + descriptor: reopened!.candidate.descriptor, + }) + return + case 'give-up': + return this.declareLost(previous) + default: + return + } + } + + /** Try every candidate once; return the first that opens and answers. */ + private async reopen(): Promise<{ client: DeviceModbusTransport; candidate: DeviceLinkCandidate } | null> { + for (const candidate of this.candidates) { + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) continue + + let client: DeviceModbusTransport + try { + client = candidate.create() + } catch { + continue + } + try { + await client.connect() + if (await this.hooks.probe(client)) return { client, candidate } + } catch { + // Still out, or open but silent — fall through and close it. + } + client.disconnect() + } + return null + } + + private declareLost(candidate: DeviceLinkCandidate): void { + const { transport, descriptor } = candidate + this.trace(`LOST: ${transport} ${descriptor} could not be recovered`) + this.close({ silent: true }) + this.hooks.emit({ status: 'error', transport, descriptor, reason: 'lost' }) + } +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/backend/editor/hardware/device-transport-factory.ts b/src/backend/editor/hardware/device-transport-factory.ts new file mode 100644 index 000000000..56b6ba3e5 --- /dev/null +++ b/src/backend/editor/hardware/device-transport-factory.ts @@ -0,0 +1,107 @@ +/** + * THE place a Modbus client is built from connection params. + * + * There used to be nine: the debug session, two lazy-reconnect paths, a transient + * one per run/stop command, the md5 verify, the license probe, the connect probe. + * Each repeated the same option literals and, worse, each decided on its own which + * transport it would accept — which is how a run/stop command over Modbus TCP came + * to open a second socket instead of using the connection already open. + * + * Building a client is now the only transport-specific step in the whole flow; + * everything downstream talks to `DeviceModbusTransport`. + */ +import type { DeviceModbusTransport } from '../../shared/debug/types' +import { ModbusTcpClient } from '../modbus/modbus-client' +import { ModbusRtuClient } from '../modbus/modbus-rtu-client' + +/** Transports that speak Modbus to a device. `websocket` (runtime v4) does not. */ +export type DeviceModbusTransportKind = 'rtu' | 'tcp' | 'simulator' + +export interface DeviceTransportParams { + connectionType?: string + /** RTU: serial port path. TCP: optional numeric port override. */ + port?: string | number + baudRate?: number + slaveId?: number + /** TCP host. `ipAddress` is accepted as an alias, as the debug specs emit that. */ + host?: string + ipAddress?: string +} + +export interface DeviceTransportOptions { + /** + * Request timeout. The default suits interactive debug traffic; the license + * probe passes a shorter one because it retries while a board is still booting. + */ + timeoutMs?: number + /** + * In-process serial port for the simulator target. Required for + * `connectionType: 'simulator'`, meaningless otherwise. + */ + virtualSerialPort?: ConstructorParameters[0]['serialPort'] +} + +/** Standard Modbus TCP port. */ +const MODBUS_TCP_PORT = 502 +const DEFAULT_TIMEOUT_MS = 5000 + +/** Which Modbus transport do these params describe, if any? */ +export function modbusTransportKind(connectionType: string | undefined): DeviceModbusTransportKind | null { + if (connectionType === 'tcp' || connectionType === 'rtu' || connectionType === 'simulator') return connectionType + // An absent type means serial, matching the license factory's long-standing default. + return connectionType === undefined ? 'rtu' : null +} + +/** + * Build an unconnected Modbus client. Returns `{ error }` rather than throwing + * when the params for the chosen transport are incomplete, so every caller + * surfaces the same message instead of inventing its own. + */ +export function buildDeviceModbusTransport( + params: DeviceTransportParams, + options: DeviceTransportOptions = {}, +): { client: DeviceModbusTransport } | { error: string } { + const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const kind = modbusTransportKind(params.connectionType) + + if (kind === 'simulator') { + if (!options.virtualSerialPort) return { error: 'The simulator transport needs an in-process serial port' } + return { + client: new ModbusRtuClient({ + port: 'simulator', + baudRate: 115200, + slaveId: 1, + timeout, + serialPort: options.virtualSerialPort, + }), + } + } + + if (kind === 'tcp') { + const host = params.host ?? params.ipAddress + if (!host) return { error: 'IP address is required for a Modbus TCP connection' } + return { + client: new ModbusTcpClient({ + host, + port: typeof params.port === 'number' ? params.port : MODBUS_TCP_PORT, + timeout, + }), + } + } + + if (kind === 'rtu') { + if (!params.port || typeof params.port !== 'string') { + return { error: 'A serial port is required for a Modbus RTU connection' } + } + return { + client: new ModbusRtuClient({ + port: params.port, + baudRate: params.baudRate ?? 115200, + slaveId: params.slaveId ?? 1, + timeout, + }), + } + } + + return { error: `Unsupported Modbus transport: ${String(params.connectionType)}` } +} diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index ea7889e39..882030a38 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -14,7 +14,7 @@ import { PackageManagerModule } from '../package-manager' import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' import { orderBoardsByVppGroup } from './order-boards-by-vpp-group' -import { mergeSerialPortList } from './serial-port-list' +import { mergeSerialPortList, toCalloutPath } from './serial-port-list' import type { AvailableBoards, HalsFile, SerialPort } from './types' const execFileAsync = promisify(execFile) @@ -113,6 +113,30 @@ class HardwareModule { return mergeSerialPortList(boardNamesByPath, manufacturersByPath) } + /** + * Is this serial port still attached? + * + * The `serialport` scan only — deliberately NOT `getAvailableSerialPorts()`, + * which also shells out to arduino-cli. This is called on every tick of the + * device link poll to tell a pulled USB cable (fail now, there is nothing to + * retry against) from a device that is merely slow to answer (retry), so it has + * to be instant. + * + * Fails SAFE: if enumeration itself breaks, the port is reported present. A + * false "gone" would tear down a working connection, which is worse than + * waiting out one timeout. + */ + async isSerialPortPresent(address: string): Promise { + try { + const ports = await NodeSerialPort.list() + const wanted = toCalloutPath(address) + return ports.some((port) => toCalloutPath(port.path) === wanted) + } catch (error: unknown) { + logger.error(`Failed to check serial port presence: ${String(error)}`) + return true + } + } + /** * `serialport` enumeration → `path → manufacturer`. This is the reliable, * instant, cross-platform source for the *set* of ports; arduino-cli only diff --git a/src/backend/editor/hardware/serial-port-list.ts b/src/backend/editor/hardware/serial-port-list.ts index 3a925a310..b752d865d 100644 --- a/src/backend/editor/hardware/serial-port-list.ts +++ b/src/backend/editor/hardware/serial-port-list.ts @@ -34,31 +34,26 @@ function toCalloutMap(byPath: Map): Map arduino-cli board name (`undefined` when * the port was detected but no board matched) - * @param manufacturersByPath path → `serialport` manufacturer/vendor string + * @param manufacturersByPath path -> `serialport` manufacturer/vendor string */ export function mergeSerialPortList( boardNamesByPath: Map, @@ -72,11 +67,14 @@ export function mergeSerialPortList( const addresses = new Set([...manufacturers.keys(), ...boardNames.keys()]) return [...addresses].map((address) => { - // Board name (more specific) wins; `||` so an empty descriptor falls through. - const descriptor = boardNames.get(address) || manufacturers.get(address) + // Empty strings are normalised away so the renderer only has to check for + // absence, not for blank-but-present descriptors. + const boardName = boardNames.get(address)?.trim() || undefined + const manufacturer = manufacturers.get(address)?.trim() || undefined return { - name: descriptor ? `${address} (${descriptor})` : address, address, + ...(boardName ? { boardName } : {}), + ...(manufacturer ? { manufacturer } : {}), } }) } diff --git a/src/backend/editor/hardware/types.ts b/src/backend/editor/hardware/types.ts index ba9d0f7b3..d42dd4fbb 100644 --- a/src/backend/editor/hardware/types.ts +++ b/src/backend/editor/hardware/types.ts @@ -4,8 +4,9 @@ import type { DebugSpec } from '../../../middleware/shared/ports/debug-spec-type import type { PlatformOption, TargetCapabilities } from '../../../middleware/shared/ports/types' const SerialPortSchema = z.object({ - name: z.string(), address: z.string(), + boardName: z.string().optional(), + manufacturer: z.string().optional(), }) type SerialPort = z.infer diff --git a/src/backend/editor/modbus/modbus-client.ts b/src/backend/editor/modbus/modbus-client.ts index fba1f2508..2e90011ba 100644 --- a/src/backend/editor/modbus/modbus-client.ts +++ b/src/backend/editor/modbus/modbus-client.ts @@ -1,4 +1,19 @@ -import type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { + buildGetBoardIdRequest, + buildGetStatusRequest, + buildPlcSetStateRequest, + parseGetBoardIdResponse, + parseGetStatusResponse, + parsePlcSetStateResponse, +} from '@root/backend/shared/debug/modbus-pdu' +import type { + DebugBoardIdResult, + DebugStatusResult, + DeviceModbusTransport, + Md5ProbeResult, + PlcControlResult, +} from '@root/backend/shared/debug/types' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { detectTargetEndian } from '@root/frontend/utils/endian' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { Socket } from 'net' @@ -12,12 +27,18 @@ export enum ModbusFunctionCode { DEBUG_GET_STATUS = 0x46, DEBUG_GET_VERSION = 0x47, DEBUG_GET_BOARD_ID = 0x48, + /** Set the runtime run/stop state. Reads go through DEBUG_GET_STATUS (0x46), + * which already reports it. */ + PLC_SET_STATE = 0x4b, } export enum ModbusDebugResponse { SUCCESS = 0x7e, ERROR_OUT_OF_BOUNDS = 0x81, ERROR_OUT_OF_MEMORY = 0x82, + /** PLC_SET_STATE only: a RUN request was refused because the hardware mode + * switch reads STOP. */ + REFUSED_BY_SWITCH = 0x86, } interface ModbusTcpClientOptions { @@ -26,7 +47,7 @@ interface ModbusTcpClientOptions { timeout: number } -export class ModbusTcpClient { +export class ModbusTcpClient implements DeviceModbusTransport { private host: string private port: number private timeout: number @@ -349,4 +370,110 @@ export class ModbusTcpClient { return { success: false, error: getErrorMessage(error) } } } + + /** + * FC 0x48 DEBUG_GET_BOARD_ID. Bare `[FC]` PDU (no payload). TCP frame is + * [MBAP:6][FC@7][...], so the pure PDU `[FC][status][id_len:u8][id_bytes...]` + * starts at offset 7 — hand it to the shared parseGetBoardIdResponse rather + * than parsing inline. + */ + async getBoardId(): Promise { + if (!this.socket) { + return { success: false, error: 'Not connected to target' } + } + + const transactionId = this.incrementTransactionId() + const protocolId = 0x0000 + const unitId = 0x00 + // buildGetBoardIdRequest() returns the [FC] PDU; the MBAP frame carries the + // function code + payload, empty for board-id. + const pdu = buildGetBoardIdRequest() + + const pduLength = 1 + pdu.length // unitId + PDU (FC only) + const request = Buffer.alloc(6 + pduLength) + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(protocolId, 2) + request.writeUInt16BE(pduLength, 4) + request.writeUInt8(unitId, 6) + Buffer.from(pdu).copy(request as unknown as Uint8Array, 7) + + try { + const data = await this.sendTcpRequest(request) + + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + + const responseTransactionId = data.readUInt16BE(0) + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + + const pduResponse = Uint8Array.prototype.slice.call(data, 7) + return parseGetBoardIdResponse(pduResponse) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + /** + * Wrap a pure PDU in a Modbus-TCP MBAP header, returning the frame and the + * transaction id to match against the reply. + * + * The older methods in this class build the same six bytes inline; new ones + * use this so the layout lives in one place. Migrating the rest is a + * mechanical follow-up, deliberately not done here. + */ + private buildTcpFrame(pdu: Uint8Array): { request: Buffer; transactionId: number } { + const transactionId = this.incrementTransactionId() + const pduLength = 1 + pdu.length // unitId + PDU + const request = Buffer.alloc(6 + pduLength) + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(0x0000, 2) // protocol id + request.writeUInt16BE(pduLength, 4) + request.writeUInt8(0x00, 6) // unit id + Buffer.from(pdu).copy(request as unknown as Uint8Array, 7) + return { request, transactionId } + } + + /** + * FC 0x46 -- runtime status (run/stop state, scan counter, uptime). + * Single read path for run/stop; see the RTU client for the rationale. + */ + async getStatus(): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildGetStatusRequest()) + const data = await this.sendTcpRequest(request) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parseGetStatusResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x4b -- ask the runtime to run or stop. Command only; reads go through + * `getStatus()`. Refused while the mode switch reads STOP. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildPlcSetStateRequest(state)) + const data = await this.sendTcpRequest(request) + if (data.length < 8) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parsePlcSetStateResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } } diff --git a/src/backend/editor/modbus/modbus-rtu-client.ts b/src/backend/editor/modbus/modbus-rtu-client.ts index 08746ab7a..5a516b79f 100644 --- a/src/backend/editor/modbus/modbus-rtu-client.ts +++ b/src/backend/editor/modbus/modbus-rtu-client.ts @@ -1,6 +1,21 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - serialport types are not available at build time but will be at runtime -import type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { + buildGetBoardIdRequest, + buildGetStatusRequest, + buildPlcSetStateRequest, + parseGetBoardIdResponse, + parseGetStatusResponse, + parsePlcSetStateResponse, +} from '@root/backend/shared/debug/modbus-pdu' +import type { + DebugBoardIdResult, + DebugStatusResult, + DeviceModbusTransport, + Md5ProbeResult, + PlcControlResult, +} from '@root/backend/shared/debug/types' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { detectTargetEndian } from '@root/frontend/utils/endian' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { SerialPort } from 'serialport' @@ -22,7 +37,7 @@ const MD5_REQUEST_RETRY_DELAY_MS = 500 const FRAME_COMPLETE_TIMEOUT_MS = 10 -export class ModbusRtuClient { +export class ModbusRtuClient implements DeviceModbusTransport { private port: string private baudRate: number private slaveId: number @@ -451,4 +466,79 @@ export class ModbusRtuClient { return { success: false, error: getErrorMessage(error) } } } + + /** + * FC 0x48 DEBUG_GET_BOARD_ID. Bare `[FC]` PDU (no payload). Response offsets + * account for the 6-byte TCP-compat padding sendRequestImpl prepends, so the + * pure PDU `[FC][status][id_len:u8][id_bytes...]` starts at offset 7 — hand it + * to the shared parseGetBoardIdResponse rather than parsing inline. + */ + async getBoardId(): Promise { + try { + // buildGetBoardIdRequest() returns the [FC] PDU; assembleRequest writes + // the function code + slaveId itself and expects only the trailing payload + // (empty for board-id), so strip the leading FC byte. + const pdu = buildGetBoardIdRequest() + const payload = Buffer.from(pdu.subarray(1)) + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_BOARD_ID, payload) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const pduResponse = Uint8Array.prototype.slice.call(response, 7) + return parseGetBoardIdResponse(pduResponse) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + /** + * FC 0x46 -- runtime status. Reports the run/stop state, the scan counter and + * uptime in one bare-FC round trip. + * + * This is the single read path for run/stop state: the frame's `running` byte + * carries it, so no second function code is needed. It also doubles as the + * liveness probe for a held link (any successful reply proves the firmware is + * answering), which is why the device liveness poll uses it. + */ + async getStatus(): Promise { + try { + // buildGetStatusRequest() returns the [FC] PDU; assembleRequest writes the + // function code + slaveId itself and expects only the trailing payload + // (empty here), so strip the leading FC byte. + const pdu = buildGetStatusRequest() + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_STATUS, Buffer.from(pdu.subarray(1))) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + return parseGetStatusResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x4b -- ask the runtime to run or stop. + * + * Command only; reads go through `getStatus()`. A RUN request is refused (not + * queued) while the mode switch reads STOP, and the result says so via + * `refusedBySwitch` so the caller can tell the user to flip the switch. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + try { + const pdu = buildPlcSetStateRequest(state) + const request = this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, Buffer.from(pdu.subarray(1))) + const response = await this.sendRequest(request) + + if (response.length < 8) { + return { success: false, error: `Invalid response: too short (${response.length} bytes)` } + } + return parsePlcSetStateResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } } diff --git a/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts new file mode 100644 index 000000000..23f3adbb5 --- /dev/null +++ b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts @@ -0,0 +1,137 @@ +/** + * Run/stop wire-protocol codec tests (FC 0x4b command, FC 0x46 read). + * + * These bytes are the contract between the editor and the baremetal runtime's + * `plcSetState()` / `debugGetStatus()` handlers in modbus_debug.cpp, so the + * layouts are asserted + * byte-for-byte rather than round-tripped through the builder. + */ + +import { buildPlcSetStateRequest, parseGetStatusResponse, parsePlcSetStateResponse } from '../modbus-pdu' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState, PlcSwitchPosition } from '../../simulator/types' + +describe('run/stop command builder (FC 0x4b)', () => { + it('builds RUN as [FC][0x01]', () => { + expect(Array.from(buildPlcSetStateRequest(PlcRuntimeState.RUNNING))).toEqual([0x4b, 0x01]) + }) + + it('builds STOP as [FC][0x00]', () => { + expect(Array.from(buildPlcSetStateRequest(PlcRuntimeState.STOPPED))).toEqual([0x4b, 0x00]) + }) + + it('claims 0x4b, clear of every other debug function code', () => { + expect(ModbusFunctionCode.PLC_SET_STATE).toBe(0x4b) + const others = Object.values(ModbusFunctionCode).filter( + (value): value is number => typeof value === 'number' && value !== ModbusFunctionCode.PLC_SET_STATE, + ) + expect(others).not.toContain(ModbusFunctionCode.PLC_SET_STATE) + }) + + it('claims 0x86 for REFUSED_BY_SWITCH, clear of every other status code', () => { + expect(ModbusDebugResponse.REFUSED_BY_SWITCH).toBe(0x86) + const others = Object.values(ModbusDebugResponse).filter( + (value): value is number => typeof value === 'number' && value !== ModbusDebugResponse.REFUSED_BY_SWITCH, + ) + expect(others).not.toContain(ModbusDebugResponse.REFUSED_BY_SWITCH) + }) +}) + +describe('parsePlcSetStateResponse', () => { + const frame = (status: number, state: number, position: number) => + new Uint8Array([ModbusFunctionCode.PLC_SET_STATE, status, state, position]) + + it('parses a running device with the switch in RUN', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN), + ) + expect(result).toEqual({ + success: true, + state: PlcRuntimeState.RUNNING, + switchPosition: PlcSwitchPosition.RUN, + }) + }) + + it('parses a stopped device with the switch in STOP', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ) + expect(result.success).toBe(true) + expect(result.state).toBe(PlcRuntimeState.STOPPED) + expect(result.switchPosition).toBe(PlcSwitchPosition.STOP) + expect(result.refusedBySwitch).toBeUndefined() + }) + + it('flags a RUN refused by the hardware switch', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.REFUSED_BY_SWITCH, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ) + // Not a success, and specifically identified so the editor shows the + // "flip the switch to RUN" warning rather than a generic failure. + expect(result.success).toBe(false) + expect(result.refusedBySwitch).toBe(true) + expect(result.state).toBe(PlcRuntimeState.STOPPED) + expect(result.switchPosition).toBe(PlcSwitchPosition.STOP) + }) + + it('reports ERROR state', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.ERROR, PlcSwitchPosition.RUN), + ) + expect(result.state).toBe(PlcRuntimeState.ERROR) + }) + + it('detects old firmware via the Modbus exception form', () => { + // A runtime built before the state machine answers (FC | 0x80). The editor turns this + // into "rebuild and upload", never an error, so field devices don't look + // broken after an editor upgrade. + const result = parsePlcSetStateResponse(new Uint8Array([0x4b + 0x80, 0x01])) + expect(result.unsupported).toBe(true) + expect(result.success).toBe(false) + }) + + it('rejects a mismatched function code', () => { + const result = parsePlcSetStateResponse(new Uint8Array([0x44, 0x7e, 0x01, 0x01])) + expect(result.success).toBe(false) + expect(result.unsupported).toBeUndefined() + expect(result.error).toMatch(/mismatch/i) + }) + + it('rejects a truncated response', () => { + expect(parsePlcSetStateResponse(new Uint8Array([])).success).toBe(false) + expect(parsePlcSetStateResponse(new Uint8Array([0x4b, 0x7e])).success).toBe(false) + }) +}) + +describe('status read carries run/stop state (FC 0x46)', () => { + /** [FC][status][running][tick:u32][uptime:u32][switch] */ + const statusFrame = (running: number, sw?: number) => { + const bytes = [ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.SUCCESS, running, 0, 0, 0, 7, 0, 0, 0, 9] + if (sw !== undefined) bytes.push(sw) + return new Uint8Array(bytes) + } + + it('reports RUNNING plus the switch position', () => { + const r = parseGetStatusResponse(statusFrame(PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN)) + expect(r.success).toBe(true) + expect(r.running).toBe(true) + expect(r.plcState).toBe(PlcRuntimeState.RUNNING) + expect(r.switchPosition).toBe(PlcSwitchPosition.RUN) + expect(r.tick).toBe(7) + expect(r.uptimeMs).toBe(9) + }) + + it('reports STOPPED with the switch in STOP', () => { + const r = parseGetStatusResponse(statusFrame(PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP)) + expect(r.running).toBe(false) + expect(r.plcState).toBe(PlcRuntimeState.STOPPED) + expect(r.switchPosition).toBe(PlcSwitchPosition.STOP) + }) + + it('omits switchPosition on firmware that predates the state machine', () => { + // 11-byte frame: the field simply is not there, which callers read as + // "no switch gating" rather than a guessed RUN. + const r = parseGetStatusResponse(statusFrame(1)) + expect(r.success).toBe(true) + expect(r.switchPosition).toBeUndefined() + }) +}) diff --git a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts index 385afa244..c65770c91 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts @@ -218,7 +218,10 @@ describe('buildGetStatusRequest / parseGetStatusResponse', () => { 0x00, // uptime = 256 ]) const result = parseGetStatusResponse(buf) - expect(result).toEqual({ success: true, running: true, tick: 42, uptimeMs: 256 }) + // `plcState` is the same byte as `running`, surfaced as the tri-state the + // run/stop state machine actually has. An 11-byte frame (this one) carries + // no switch position, so the field is absent. + expect(result).toEqual({ success: true, running: true, plcState: 1, tick: 42, uptimeMs: 256 }) }) it('reports running=false when the flag byte is zero', () => { diff --git a/src/backend/shared/debug/modbus-pdu.ts b/src/backend/shared/debug/modbus-pdu.ts index 5fcd2856b..ed2bb916e 100644 --- a/src/backend/shared/debug/modbus-pdu.ts +++ b/src/backend/shared/debug/modbus-pdu.ts @@ -23,6 +23,9 @@ * getList request: [FC=0x44] [numIndexes: U16BE] [arr0:U8 elem0:U16BE] [arr1:U8 elem1:U16BE] ... * getList response: [FC=0x44] [status] [lastIndex: U16BE] [tick: U32BE] [size: U16BE] [data...] * + * plcSetState request: [FC=0x4b] [state: U8] (0 = STOP, 1 = RUN) + * plcSetState response: [FC=0x4b] [status] [plcState: U8] [switchPosition: U8] + * * set request: [FC=0x42] [arr: U8] [elem: U16BE] [force: U8] [dataLen: U16BE] [value...] * set response: [FC=0x42] [status] * @@ -41,7 +44,7 @@ */ import { detectTargetEndian, type TargetEndian } from '../../../frontend/utils/endian' -import { ModbusDebugResponse, ModbusFunctionCode } from '../simulator/types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from '../simulator/types' import type { DebugBoardIdResult, DebugSetResult, @@ -49,6 +52,7 @@ import type { DebugTransportResult, DebugVersionResult, Md5ProbeResult, + PlcControlResult, } from './types' // --------------------------------------------------------------------------- @@ -278,7 +282,14 @@ export function parseSetVariableResponse(data: Uint8Array): DebugSetResult { /** * Parse a status response (FC 0x46). - * Layout: `[FC][status][running:u8][tick:u32BE][uptime:u32BE]` (11 PDU bytes). + * Layout: `[FC][status][running:u8][tick:u32BE][uptime:u32BE][switch:u8]` + * (12 PDU bytes; 11 on firmware predating the run/stop state machine). + * + * This is the ONE read path for run/stop state — `running` was always this + * frame's first payload byte, so reporting the real state there rather than a + * hardcoded 1 costs no extra round trip and needs no second function code. The + * switch position is appended, which older parsers ignore and older firmware + * simply omits. */ export function parseGetStatusResponse(data: Uint8Array): DebugStatusResult { if (data.length < 2) { @@ -300,11 +311,18 @@ export function parseGetStatusResponse(data: Uint8Array): DebugStatusResult { return { success: false, error: `Incomplete status response (${data.length} bytes, expected 11)` } } + const running = readU8(data, 2) return { success: true, - running: readU8(data, 2) !== 0, + running: running !== 0, + // Same byte as `running`, as the tri-state the run/stop machine actually + // has (STOPPED / RUNNING / ERROR). + plcState: running, tick: readU32BE(data, 3), uptimeMs: readU32BE(data, 7), + // Appended by firmware carrying the run/stop state machine; absent on older + // firmware, which callers read as "no switch gating". + ...(data.length >= 12 ? { switchPosition: readU8(data, 11) } : {}), } } @@ -377,3 +395,60 @@ export function parseGetBoardIdResponse(data: Uint8Array): DebugBoardIdResult { export function responseFunctionCode(data: Uint8Array): number | undefined { return data.length > 0 ? readU8(data, 0) : undefined } + +// --------------------------------------------------------------------------- +// FC 0x4b — run/stop command +// +// Command only. Reading the state is `buildGetStatusRequest` / +// `parseGetStatusResponse` (FC 0x46) above, which already reports it. +// --------------------------------------------------------------------------- + +export function buildPlcSetStateRequest(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Uint8Array { + const pdu = alloc(2) + writeU8(pdu, 0, ModbusFunctionCode.PLC_SET_STATE) + writeU8(pdu, 1, state === PlcRuntimeState.RUNNING ? 1 : 0) + return pdu +} + +/** + * Parse a run/stop command acknowledgement. + * + * Three outcomes the caller must tell apart: + * - success: the request was accepted; `state` is as of the last scan. + * - `refusedBySwitch`: a RUN was rejected because the hardware switch reads + * STOP. The editor turns this into the "flip the switch" warning, not an + * error. + * - `unsupported`: the target answered the Modbus exception form (FC | 0x80), + * i.e. firmware built before the run/stop state machine. The editor degrades + * to "rebuild and upload" so field devices never look broken. + */ +export function parsePlcSetStateResponse(data: Uint8Array): PlcControlResult { + if (data.length < 1) { + return { success: false, error: 'Response too short' } + } + + const fc = readU8(data, 0) + if (fc === (ModbusFunctionCode.PLC_SET_STATE as number) + 0x80) { + return { success: false, unsupported: true, error: 'Firmware does not implement run/stop control' } + } + if (fc !== (ModbusFunctionCode.PLC_SET_STATE as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (data.length < 4) { + return { success: false, error: 'Response too short' } + } + + const status = readU8(data, 1) + const result: PlcControlResult = { + success: status === (ModbusDebugResponse.SUCCESS as number), + state: readU8(data, 2), + switchPosition: readU8(data, 3), + } + if (status === (ModbusDebugResponse.REFUSED_BY_SWITCH as number)) { + result.refusedBySwitch = true + result.error = 'Refused: the hardware mode switch is in STOP' + } else if (!result.success) { + result.error = statusError(status) + } + return result +} diff --git a/src/backend/shared/debug/types.ts b/src/backend/shared/debug/types.ts index 05cd678ca..78e934069 100644 --- a/src/backend/shared/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -1,3 +1,5 @@ +import type { PlcRuntimeState } from '../simulator/types' + /** * Debug Transport Interface * @@ -33,6 +35,15 @@ export interface DebugStatusResult { running?: boolean tick?: number uptimeMs?: number + /** Run/stop state (0 = STOPPED, 1 = RUNNING, 2 = ERROR). This is the single + * read path for run/stop — there is no separate query FC. `running` above is + * the same information as a boolean, kept for callers that only need + * liveness. Absent on firmware predating the run/stop state machine. */ + plcState?: number + /** Mode-switch position (0 = STOP, 1 = RUN). Boards with no physical switch + * report RUN. Absent on firmware predating the run/stop state machine, which + * callers should read as "no gating". */ + switchPosition?: number error?: string } @@ -82,3 +93,129 @@ export interface DebugTransport { getVariablesList(indexes: number[]): Promise setVariable(index: number, force: boolean, valueBuffer?: Uint8Array): Promise } + +/** + * The channel-level operations every medium offers, independent of the debug + * payload surface: open/close, the board-id read (FC 0x48) that classifies + * whether a firmware is answering at all, and — for baremetal targets — run/stop. + * + * The same PDUs ride serial (ModbusRtuClient), TCP (ModbusTcpClient) and the + * runtime-v4 debug WebSocket (WebSocketDebugTransport), so a connection is + * established and classified identically on every target. + */ +export interface DeviceChannelTransport { + connect(): Promise + disconnect(): void + /** Board-id read (FC 0x48) — the readiness probe that says whether an OpenPLC + * firmware is answering at all. Optional for the same reason as `getStatus`: + * it is a BAREMETAL question. The runtime-v4 WebSocket talks to a target whose + * identity came from the REST login, so it never answers this. */ + getBoardId?(): Promise + /** Runtime status (FC 0x46): run/stop state, mode-switch position, scan + * counter, uptime. Doubles as the liveness probe for a held link — any + * successful reply proves the firmware is answering — so the device liveness + * poll prefers it and gets the run/stop state for free. + * + * Optional because run/stop is a BAREMETAL concern: the Modbus RTU/TCP + * clients implement it, while the runtime-v4 WebSocket transport does not — + * v4 drives run/stop over its REST API, so implementing it there would be + * dead code. */ + getStatus?(): Promise + /** Run/stop command (FC 0x4b). Reads go through `getStatus()`. Optional for + * the same reason as `getStatus`. */ + setPlcState?(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise +} + +/** + * What a DEBUG channel must offer, whatever medium it runs over: the channel + * operations plus the debug payload surface. + * + * Deliberately narrower than `DeviceModbusTransport`: it does NOT require + * `getStatus` / `setPlcState`, because those are CONTROL operations and a debug + * channel is not always the control channel. The runtime-v4 WebSocket implements + * exactly this and nothing more — v4 is controlled over REST. + */ +export interface DeviceDebugChannel extends DeviceChannelTransport { + getMd5Hash(): Promise + getVariablesList(indexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + /** `Buffer | Uint8Array`: the Node Modbus clients hand back the former, the + * browser-shared WebSocket transport the latter, and TypeScript does not + * treat one as a substitute for the other. */ + data?: Uint8Array | Buffer + error?: string + }> + setVariable(index: number, force: boolean, valueBuffer?: Uint8Array | Buffer): Promise +} + +/** + * The full command surface of a Modbus link to a device: the debug operations + * (`DebugTransport`) plus the channel operations (`DeviceChannelTransport`) + * plus run/stop. + * + * `ModbusRtuClient` and `ModbusTcpClient` are separate classes that differ only + * in framing (RTU: slave id + CRC; TCP: MBAP header). The PDUs they carry, and + * therefore the operations they expose, are identical. Naming that shared + * surface is what lets ONE held connection serve every caller regardless of how + * it was established, instead of each caller picking a client class and opening + * its own connection. + * + * A caller-by-caller choice is exactly what broke run/stop over Modbus TCP: the + * command path recognised only RTU clients as reusable, so with a live TCP link + * it opened a second socket — which an Arduino Modbus TCP server, serving one + * client at a time, never answered. + * + * `getStatus` / `setPlcState` are REQUIRED here, narrowing the optionals on + * `DeviceChannelTransport`: both Modbus clients implement run/stop, and only the + * runtime-v4 WebSocket (a different protocol, driving run/stop over REST) does + * not. + */ +export interface DeviceModbusTransport + extends Omit, + DeviceChannelTransport { + getBoardId(): Promise + getStatus(): Promise + setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise + /** + * The two payload-carrying operations, restated for the main process. + * + * `DebugTransport` types payloads as `Uint8Array` because it is also + * implemented in the browser-shared layer; the Node Modbus clients hand back + * `Buffer`, which TypeScript does not treat as a substitute for `Uint8Array` + * since @types/node made `Buffer` generic. Everything else — connect, + * disconnect, getMd5Hash — is inherited unchanged. + */ + getVariablesList(indexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: Buffer + error?: string + }> + setVariable(index: number, force: boolean, valueBuffer?: Buffer): Promise +} + +/** + * Result of a run/stop command (FC 0x4b `PLC_SET_STATE`). + * + * Reads are NOT done through this — they come from `DebugStatusResult` via + * FC 0x46. This is the command's acknowledgement, which carries the resulting + * state so a caller can react without waiting for the next poll. + */ +export interface PlcControlResult { + success: boolean + /** State as of the target's last scan cycle. The runtime derives the new + * state inside its next cycle, so a caller that needs the settled value + * reads it from the next status poll (at most one scan period later). */ + state?: number + switchPosition?: number + /** A RUN request was refused because the switch reads STOP. Drives the + * "flip the switch to RUN" warning. */ + refusedBySwitch?: boolean + /** Firmware predates the run/stop state machine. Drives an informational + * "rebuild and upload" message instead of an error. */ + unsupported?: boolean + error?: string +} diff --git a/src/backend/shared/debug/websocket-debug-transport.ts b/src/backend/shared/debug/websocket-debug-transport.ts index 5a9e39236..44b62678f 100644 --- a/src/backend/shared/debug/websocket-debug-transport.ts +++ b/src/backend/shared/debug/websocket-debug-transport.ts @@ -31,7 +31,14 @@ import { parseGetMd5Response, parseSetVariableResponse, } from './modbus-pdu' -import type { DebugSetResult, DebugTransport, DebugTransportResult, Md5ProbeResult } from './types' +import type { + DebugBoardIdResult, + DebugSetResult, + DebugTransport, + DebugTransportResult, + DeviceDebugChannel, + Md5ProbeResult, +} from './types' const REQUEST_TIMEOUT_MS = 5000 const CONNECT_TIMEOUT_MS = 5000 @@ -67,7 +74,7 @@ function hexSpacedToBytes(hex: string): Uint8Array { return out } -export class WebSocketDebugTransport implements DebugTransport { +export class WebSocketDebugTransport implements DebugTransport, DeviceDebugChannel { private host: string private port: number private token: string diff --git a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts new file mode 100644 index 000000000..783414209 --- /dev/null +++ b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts @@ -0,0 +1,312 @@ +/** + * The Connect flow and the debugger resolve the SAME debug spec. + * + * `use-device-connect.ts` resolves it (via `resolveDeviceLinkCandidates`) to derive + * the ways it can OPEN a connection, with nothing connected + * yet — that is the whole point of Connect. So a precondition on a baremetal + * board's spec gates Connect as well as the debugger, and Connect can then never + * succeed: it would need a connection to establish one. The user-visible symptom + * was "Select a communication port for this device first" with a port already + * selected, because the resolver returned `error` instead of `config`. + * + * A debugger-only requirement therefore cannot be expressed as a spec + * precondition; it belongs in the debugger entry point. These tests pin both + * halves of that. + */ +import { + resolveDebugConnection, + resolveDeviceLinkCandidates, + type DebugResolverContext, + type DebugSpec, +} from '../debug-spec' + +/** What an Arduino target declares: serial always, TCP when an ethernet shield is + * configured. The ORDER is the capability matrix's, not the resolver's. */ +const ARDUINO_TRANSPORTS = ['modbus-serial', 'modbus-tcp'] as const + +/** Mirrors what `buildUsbResolverContext` builds: nothing is connected. + * `port === undefined` models "no port selected", which is how the store looks + * before the user picks one — the builder omits the key entirely. */ +const disconnectedUsbContext = (port?: string): DebugResolverContext => ({ + state: { + configuration: { deviceBoard: 'AutomationDirect P1AM-100', ...(port !== undefined ? { communicationPort: port } : {}) }, + screens: { modbus_rtu: { enabled: true, rtu_baud_rate: '115200', rtu_slave_id: 1 } }, + runtimeConnection: {}, + promptCache: {}, + }, + capabilities: { runtimeConnected: false, jwtToken: false }, +}) + +/** Shaped like the P1AM package's `debug` block: an RTU channel whose params + * come from the selected port and the Modbus screen. */ +const baremetalSpec: DebugSpec = { + channels: [ + { + label: 'Modbus RTU', + channel: 'rtu', + enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, + params: { + port: { $ref: 'configuration.communicationPort', required: 'No serial port selected.' }, + baudRate: { $ref: 'screens.modbus_rtu.rtu_baud_rate', default: '115200', as: 'number' }, + slaveId: { $ref: 'screens.modbus_rtu.rtu_slave_id', default: 1, as: 'number' }, + }, + }, + ], +} + +/** A P1AM-shaped spec with BOTH transports declared — the real package shape. + * Which one is eligible is decided by the project's Modbus screens. */ +const bothChannelsSpec: DebugSpec = { + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, + params: { host: { $ref: 'screens.modbus_tcp.tcp_ip' }, port: 502 }, + }, + ...baremetalSpec.channels, + ], +} + +/** Only Modbus TCP enabled, with a serial port selected in the dropdown. */ +const tcpOnlyContext = (): DebugResolverContext => ({ + state: { + configuration: { deviceBoard: 'AutomationDirect P1AM-100', communicationPort: '/dev/cu.usbmodem11101' }, + screens: { + modbus_tcp: { enabled: true, tcp_ip: '192.168.0.50' }, + modbus_rtu: { enabled: false, rtu_baud_rate: '115200', rtu_slave_id: 1 }, + }, + runtimeConnection: {}, + promptCache: {}, + }, + capabilities: { runtimeConnected: false, jwtToken: false }, +}) + +describe('Connect resolves a baremetal debug spec while disconnected', () => { + it('returns an rtu config carrying the selected port', () => { + const result = resolveDebugConnection(baremetalSpec, disconnectedUsbContext('/dev/cu.usbmodem11101'), undefined) + + // `kind: 'config'` + rtu is exactly what use-device-connect requires before + // it will call device.connect(); anything else becomes "Select a + // communication port for this device first". + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('rtu') + expect(String(result.config.connectionParams.port)).toBe('/dev/cu.usbmodem11101') + expect(Number(result.config.connectionParams.baudRate)).toBe(115200) + } + }) + + it('still reports a genuinely missing port, so that message is not lost', () => { + // The store omits `communicationPort` until one is picked; that is what the + // spec's `required` message exists for, and it must survive the fix above. + const result = resolveDebugConnection(baremetalSpec, disconnectedUsbContext(), undefined) + expect(result.kind).toBe('error') + expect(result).toMatchObject({ body: 'No serial port selected.' }) + }) + + it('auto-selects TCP in a Modbus-TCP-only project, which Connect cannot use', () => { + // Second regression, same misleading dialog. A project with ONLY Modbus TCP + // enabled leaves exactly one eligible channel — tcp — so auto-select returns + // a tcp config. Connect opens serial and nothing else, so it rejected that + // config and reported "Select a communication port" with a port selected. + const result = resolveDebugConnection(bothChannelsSpec, tcpOnlyContext(), undefined) + + expect(result.kind).toBe('config') + if (result.kind === 'config') expect(result.config.connectionType).toBe('tcp') + }) + + it('offers BOTH transports, SERIAL first, when the project enables Modbus TCP', () => { + // Serial leads: it is the direct, local path, with no address to be stale and + // nothing to ask the user. Modbus TCP is the remote fallback. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu', 'tcp']) + expect(String(result.candidates[0].config.connectionParams.port)).toBe('/dev/cu.usbmodem11101') + }) + + it('offers serial even with Modbus RTU turned off', () => { + // Modbus RTU disabled does not mean serial is unreachable: the always-on + // debugger keeps the serial protocol compiled into every baremetal firmware. + // Requiring `enabledWhen` here is what made Connect refuse a Modbus-TCP-only + // project with "select a communication port" while one was plainly selected. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }) + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.some((candidate) => candidate.config.connectionType === 'rtu')).toBe(true) + }) + + it('offers serial ALONE when Modbus TCP is not enabled', () => { + const rtuOnly = resolveDeviceLinkCandidates(bothChannelsSpec, disconnectedUsbContext('/dev/cu.usbmodem11101'), { + transports: [...ARDUINO_TRANSPORTS], + }) + if (rtuOnly.kind !== 'candidates') throw new Error('expected candidates') + expect(rtuOnly.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + + it('resolves a Runtime v4 target, whose only transport is a WebSocket', () => { + // The regression that broke every v4 target: with eligibility hardcoded to + // serial-then-TCP, a `websocket` channel was never a candidate, so no session + // was opened and every command answered "not connected" on a target the user + // had connected to and uploaded to. Eligibility comes from the TARGET's + // declared transports, so this needs no special case — only the right facts. + const v4Spec: DebugSpec = { + preconditions: ['runtimeConnected', 'jwtToken'], + channels: [ + { + label: 'WebSocket', + channel: 'websocket', + enabledWhen: true, + params: { + ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not configured.' }, + jwtToken: { $ref: 'runtimeConnection.jwtToken', required: 'JWT token missing.' }, + }, + }, + ], + } + const connectedRuntime: DebugResolverContext = { + state: { + configuration: { deviceBoard: 'OpenPLC Runtime v4', runtimeIpAddress: '192.168.0.42' }, + screens: {}, + runtimeConnection: { connectionStatus: 'connected', jwtToken: 'jwt' }, + promptCache: {}, + }, + capabilities: { runtimeConnected: true, jwtToken: true }, + } + + const result = resolveDeviceLinkCandidates(v4Spec, connectedRuntime, { transports: ['websocket'] }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['websocket']) + expect(result.candidates[0].config.connectionParams.jwtToken).toBe('jwt') + }) + + it('resolves a Runtime v3 target over Modbus TCP', () => { + const v3Spec: DebugSpec = { + preconditions: ['runtimeConnected'], + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not set.' } }, + }, + ], + } + const connectedRuntime: DebugResolverContext = { + state: { + configuration: { deviceBoard: 'OpenPLC Runtime v3', runtimeIpAddress: '192.168.0.9' }, + screens: {}, + runtimeConnection: { connectionStatus: 'connected' }, + promptCache: {}, + }, + capabilities: { runtimeConnected: true, jwtToken: false }, + } + + const result = resolveDeviceLinkCandidates(v3Spec, connectedRuntime, { transports: ['modbus-tcp'] }) + + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['tcp']) + }) + + it('ignores a channel the target cannot actually speak', () => { + // A spec may declare more than the target supports; the capability matrix wins. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: ['modbus-serial'] }) + + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + it('lets a caller skip a channel it has decided against', () => { + // Channel 0 in this spec is the TCP one. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS], skipChannels: [0] }) + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + describe('a DHCP address is asked for LAST, and only if needed', () => { + const dhcpSpec: DebugSpec = { + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, + params: { ipAddress: { $ref: 'screens.modbus_tcp.ip_address' } }, + prompts: [ + { + when: { $ref: 'screens.modbus_tcp.enable_dhcp' }, + field: 'ipAddress', + title: 'Target IP Address', + message: 'Enter the DHCP-assigned address.', + cacheKey: 'lastDhcpIp', + }, + ], + }, + ...baremetalSpec.channels, + ], + } + const dhcpContext = (): DebugResolverContext => { + const context = tcpOnlyContext() + context.state.screens.modbus_tcp = { enabled: true, enable_dhcp: true } + return context + } + + it('sets the DHCP channel aside instead of asking, when prompts are deferred', () => { + // The user's report: with DHCP on, Connect hung on a dialog before trying + // anything. With a cable attached, that question is pure interruption. + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + expect(result.awaitingInput).toHaveLength(1) + }) + + it('asks once the caller resolves that channel on its own', () => { + // The second pass, run only after everything silent has failed. + const deferred = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + if (deferred.kind !== 'candidates') throw new Error('expected candidates') + + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], onlyChannels: deferred.awaitingInput }) + expect(result.kind).toBe('prompt') + }) + + it('reports candidates even when ONLY a prompting channel is eligible', () => { + // No serial port selected and DHCP on: there is nothing to try silently, but + // the attempt must not be reported as impossible — the address dialog is + // exactly what is missing. + const context = dhcpContext() + delete context.state.configuration.communicationPort + const result = resolveDeviceLinkCandidates(dhcpSpec, context, { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates).toHaveLength(0) + expect(result.awaitingInput).toHaveLength(1) + }) + }) + + it('still reports a missing port when serial is the only candidate', () => { + // Candidate resolution must not swallow a channel's own `required` message. + const result = resolveDeviceLinkCandidates(baremetalSpec, disconnectedUsbContext(), { transports: [...ARDUINO_TRANSPORTS] }) + expect(result).toMatchObject({ kind: 'error', body: 'No serial port selected.' }) + }) + + it('reports unsupported when the board declares nothing reachable', () => { + const malformed = {} as unknown as DebugSpec + expect(resolveDeviceLinkCandidates(malformed, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe('unsupported') + expect(resolveDeviceLinkCandidates(undefined, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe('unsupported') + }) + + it('shows why a precondition cannot express a debugger-only requirement', () => { + // Adding ANY precondition to the spec above breaks Connect, because Connect + // resolves this same spec with nothing connected. + const gated: DebugSpec = { ...baremetalSpec, preconditions: ['runtimeConnected'] } + + const result = resolveDebugConnection(gated, disconnectedUsbContext('/dev/cu.usbmodem11101'), undefined) + expect(result.kind).toBe('error') + }) +}) diff --git a/src/backend/shared/hardware/debug-spec.ts b/src/backend/shared/hardware/debug-spec.ts index ba192a973..963dcf24a 100644 --- a/src/backend/shared/hardware/debug-spec.ts +++ b/src/backend/shared/hardware/debug-spec.ts @@ -19,6 +19,7 @@ import type { DebugCondition, DebugParam, DebugRef, DebugSpec } from '../../../middleware/shared/ports/debug-spec-types' import type { DebugConnectionConfig } from '../../../middleware/shared/ports/types' +import type { DebuggerTransport } from '../../../middleware/shared/utils/target-capabilities' // Re-export types so importers have one canonical entry point. The // types themselves live in the ports layer (architecture rule); the @@ -111,6 +112,44 @@ export type DebugResolverOutcome = | { kind: 'error'; title: string; body: string } | { kind: 'unsupported' } +/** + * Which capability transport a declared channel kind belongs to. + * + * `simulator` maps to `modbus-serial` because that is what it is: RTU over the + * emulated serial port the in-process simulator exposes. + */ +const CHANNEL_TRANSPORT: Record = { + rtu: 'modbus-serial', + simulator: 'modbus-serial', + tcp: 'modbus-tcp', + websocket: 'websocket', +} + +/** One resolved way to reach the device, with the channel it came from. */ +export interface DeviceLinkCandidateConfig { + config: DebugConnectionConfig + channelLabel: string + /** Index in `spec.channels`, so a caller can skip this channel on a re-resolve. */ + channelIndex: number +} + +/** + * Outcome of resolving link candidates. Shares `prompt` / `error` / `unsupported` + * with `DebugResolverOutcome` so ONE renderer loop can drive both this and the + * debugger's single-channel resolution. + */ +export type DeviceLinkCandidatesOutcome = + | { + kind: 'candidates' + candidates: DeviceLinkCandidateConfig[] + /** + * Channels that could be tried, but only after asking the user something + * (a DHCP address). Empty unless the caller passed `deferPrompts`. + */ + awaitingInput: number[] + } + | Extract + // --------------------------------------------------------------------------- // Resolver // --------------------------------------------------------------------------- @@ -292,3 +331,128 @@ export function resolveDebugConnection( }, } } + +/** + * Resolve the ordered ways to reach a target — ANY target. + * + * The caller does not pick a medium; it gets candidates in preference order and the + * connection manager tries them until one answers. That makes this the single + * interpreter of a `debug` spec, whatever kind of target declared it: + * + * 1. `rtu` — serial. Always a candidate for a board that declares it, regardless + * of whether Modbus RTU is enabled, because the always-on debugger keeps the + * serial protocol compiled into every baremetal firmware. Preferred because it + * is the direct, local, physically unambiguous path: if a cable is attached, + * that is the device in front of you, with no address to be stale and nothing + * to ask the user. + * 2. `tcp` — Modbus TCP, when enabled. A baremetal board's remote path, and a + * Runtime v3's debug channel. + * 3. `websocket` — a Runtime v4's debug channel. + * 4. `simulator` — in-process. + * + * In practice the sets are disjoint: a baremetal board declares serial and possibly + * Modbus TCP, a runtime declares exactly one network channel, the simulator one + * in-process channel. So the ordering only ever decides anything for a baremetal + * board — but it is expressed once, for every kind, rather than once per caller. + * Resolving a runtime's channel through a serial-and-TCP-only version of this + * function is what left Runtime v4 targets with no session at all. + * + * Order is a preference, not a promise: the manager still verifies each candidate + * before keeping it, so a cable attached to a board with no firmware falls through + * to the next option rather than stranding the user. + * + * A channel needing user input (a DHCP address) is asked for LAST — see + * `deferPrompts` — so a user with a cable attached is never interrupted by a + * question about an address they do not need to know. + * + * If nothing can be built the caller gets the reason the first candidate failed: an + * editor reporting "connected" with nothing connected is what makes every later + * request time out for no visible reason. + */ +export function resolveDeviceLinkCandidates( + spec: DebugSpec | undefined, + context: DebugResolverContext, + options: { + /** + * The target's `debuggerTransports`, in preference order, from its capability + * matrix. Required: which media a target speaks is a fact about the target, and + * the resolver has no business guessing it. + */ + transports: DebuggerTransport[] + /** Channels to leave out — e.g. one the user has declined. */ + skipChannels?: number[] + /** Consider ONLY these channels. Used for the second pass, after a prompt. */ + onlyChannels?: number[] + /** + * Don't ask the user anything: a channel that needs input is left out and + * reported in `awaitingInput` instead of bubbling up as a `prompt`. Lets a + * caller try everything that works silently before interrupting anyone. + */ + deferPrompts?: boolean + }, +): DeviceLinkCandidatesOutcome { + // `channels` arrives from a VPP manifest, so treat it as possibly absent + // rather than trusting the type: a malformed package must produce a dialog, + // not an exception inside a click handler. + const channels = spec?.channels + if (!spec || !channels?.length) return { kind: 'unsupported' } + + const transports = options.transports + const skip = new Set(options.skipChannels ?? []) + const only = options.onlyChannels ? new Set(options.onlyChannels) : null + const included = (index: number): boolean => !skip.has(index) && (only === null || only.has(index)) + + // Order and eligibility come from the TARGET's declared transports, not from any + // list kept here: `debuggerTransports` already says which media a target speaks + // and in what order (`['modbus-serial', 'modbus-tcp']` for an Arduino board, + // `['websocket']` for a Runtime v4, `['modbus-tcp']` for a v3). Honouring that + // declaration is what makes one resolver serve every kind of target — and a + // channel the target cannot actually speak is not a candidate, however the spec + // describes it. + const eligible: number[] = [] + for (const transport of transports) { + channels.forEach((channel, index) => { + if (!included(index) || CHANNEL_TRANSPORT[channel.channel] !== transport) return + // Serial is exempt from `enabledWhen`: "Modbus RTU disabled" does not mean the + // board is unreachable over serial, because the always-on debugger keeps the + // serial protocol compiled in either way. Every other kind must be turned on. + if (channel.channel !== 'rtu' && !evaluateCondition(channel.enabledWhen, context.state)) return + eligible.push(index) + }) + } + + if (eligible.length === 0) { + const message = spec.messages?.noneEnabled + return { + kind: 'error', + title: message?.title ?? 'No Connection Channel', + body: message?.body ?? 'This target declares no channel the editor can connect through.', + } + } + + const candidates: DeviceLinkCandidateConfig[] = [] + const awaitingInput: number[] = [] + let firstFailure: Extract | null = null + + for (const index of eligible) { + const outcome = resolveDebugConnection(spec, context, index) + if (outcome.kind === 'config') { + candidates.push({ config: outcome.config, channelLabel: outcome.channelLabel, channelIndex: index }) + continue + } + if (outcome.kind === 'prompt') { + if (options.deferPrompts) { + awaitingInput.push(index) + continue + } + return outcome + } + // 'pick' cannot occur: every resolve above names its channel by index. + if (outcome.kind === 'error' || outcome.kind === 'unsupported') firstFailure ??= outcome + } + + if (candidates.length === 0 && awaitingInput.length === 0) { + return firstFailure ?? { kind: 'unsupported' } + } + return { kind: 'candidates', candidates, awaitingInput } +} diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 56ff8af21..8f5e6edc1 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -574,7 +574,9 @@ describe('ModbusRtuClient', () => { await connectClient() autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, statusPayload(1, 42, 256))) const result = await client.getStatus() - expect(result).toEqual({ success: true, running: true, tick: 42, uptimeMs: 256 }) + // `plcState` mirrors `running` as the run/stop machine's tri-state; the + // fixture frame carries no switch byte, so that field stays absent. + expect(result).toEqual({ success: true, running: true, plcState: 1, tick: 42, uptimeMs: 256 }) }) it('reports running=false when the flag byte is zero', async () => { diff --git a/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts b/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts new file mode 100644 index 000000000..6b540b7f0 --- /dev/null +++ b/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts @@ -0,0 +1,254 @@ +/** + * End-to-end validation of the baremetal run/stop state machine over avr8js. + * + * Boots simulator firmware in avr8js and exercises the run/stop wire protocol against it: + * query, stop, output de-energisation, program re-initialisation, restart, and + * that the debug channel survives a stop. + * + * The firmware must be built first, by the editor's own compile pipeline. Same + * gating style as debug-e2e.test.ts: the test skips unless the artefacts are + * pointed at, so CI without an AVR toolchain stays green. + * + * PLC_CONTROL_HEX=/path/to/Baremetal.ino.hex \ + * PLC_CONTROL_DEBUG_MAP=/path/to/debug-map.json \ + * npx jest src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts + * + * The firmware must be built from a program with a `counter : INT := 0` + * variable incremented every scan and a `pulse AT %QX0.0 : BOOL` driven + * unconditionally TRUE -- `counter` proves execution and re-init, `pulse` + * proves the stop clamp. + */ + +import fs from 'node:fs' +import path from 'node:path' + +import { ModbusRtuClient } from '../modbus-rtu-client' +import { SimulatorModule } from '../simulator-module' +import { PlcRuntimeState, PlcSwitchPosition } from '../types' +import { VirtualSerialPort } from '../virtual-serial-port' + +// jsdom polyfill -- matches modbus-rtu-client.test.ts. +if (typeof globalThis.TextDecoder === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { TextEncoder, TextDecoder } = require('util') + globalThis.TextEncoder = TextEncoder + globalThis.TextDecoder = TextDecoder as typeof globalThis.TextDecoder +} + +const HEX_PATH = process.env.PLC_CONTROL_HEX ?? '' +const MAP_PATH = process.env.PLC_CONTROL_DEBUG_MAP ?? '' +const ENABLED = HEX_PATH !== '' && MAP_PATH !== '' && fs.existsSync(HEX_PATH) && fs.existsSync(MAP_PATH) +const describeIfEnabled: typeof describe = ENABLED ? describe : describe.skip + +// Second firmware, built from the same program but with a HAL that overrides +// `hardwareStateSwitch()` to read STOP for the first 3 seconds of uptime and +// RUN afterwards. Exercises the paths a switchless board can't reach. +const SWITCH_HEX_PATH = process.env.PLC_CONTROL_SWITCH_HEX ?? '' +const SWITCH_ENABLED = SWITCH_HEX_PATH !== '' && fs.existsSync(SWITCH_HEX_PATH) +const describeIfSwitch: typeof describe = SWITCH_ENABLED ? describe : describe.skip + +/** Resolve a variable path from debug-map.json into the packed + * `(arr << 16) | elem` address the debug FCs take. */ +function resolveDebugAddr(debugMapJson: string, pathSuffix: string): number { + const map = JSON.parse(debugMapJson) as { + leaves: Array<{ arrayIdx: number; elemIdx: number; path: string }> + } + const leaf = map.leaves.find((l) => l.path.toUpperCase().endsWith(pathSuffix.toUpperCase())) + if (!leaf) { + throw new Error(`No debug leaf matching "${pathSuffix}". Available: ${map.leaves.map((l) => l.path).join(', ')}`) + } + return (leaf.arrayIdx << 16) | leaf.elemIdx +} + +describeIfEnabled('Baremetal run/stop state machine end-to-end (FC 0x4b + 0x46 over avr8js)', () => { + let sim: SimulatorModule + let client: ModbusRtuClient + let counterAddr: number + let pulseAddr: number + + /** Read a single variable's raw bytes via FC 0x44. */ + async function readVar(addr: number): Promise { + const res = await client.getVariablesList([addr]) + if (!res.success || !res.data) throw new Error(`getVariablesList failed: ${res.error ?? 'no data'}`) + return res.data + } + + async function readCounter(): Promise { + // FC 0x44's payload is the requested variables' raw bytes concatenated, + // with no per-variable size prefix. One INT => 2 bytes, little-endian. + const data = await readVar(counterAddr) + expect(data.length).toBe(2) + return data[0] | (data[1] << 8) + } + + async function readPulse(): Promise { + const data = await readVar(pulseAddr) + expect(data.length).toBe(1) + return data[0] + } + + /** Let the target run for a while in real time so scan cycles elapse. */ + async function settle(ms = 400): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) + } + + beforeAll(async () => { + const hex = fs.readFileSync(path.resolve(HEX_PATH), 'utf-8') + const debugMapJson = fs.readFileSync(path.resolve(MAP_PATH), 'utf-8') + counterAddr = resolveDebugAddr(debugMapJson, 'counter') + pulseAddr = resolveDebugAddr(debugMapJson, 'pulse') + + sim = new SimulatorModule() + sim.loadAndRun(hex) + client = new ModbusRtuClient({ slaveId: 1, timeout: 5000, serialPort: new VirtualSerialPort(sim) }) + await client.connect() + }, 600000) + + afterAll(() => { + client?.disconnect() + sim?.stop() + }) + + it('boots RUNNING with the virtual switch in RUN', async () => { + const state = await client.getStatus() + expect(state.success).toBe(true) + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + // The simulator HAL implements no hardwareStateSwitch() override, so the + // weak default must report RUN -- this is the "nothing changes for boards + // that opt out" guarantee. + expect(state.switchPosition).toBe(PlcSwitchPosition.RUN) + }, 30000) + + it('executes the program while running', async () => { + const first = await readCounter() + await settle() + const second = await readCounter() + expect(second).not.toBe(first) + }, 30000) + + it('drives the located output TRUE while running', async () => { + expect(await readPulse()).toBe(1) + }, 30000) + + it('run/stop command STOPs the PLC to STOPPED', async () => { + const res = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(res.success).toBe(true) + await settle() + const state = await client.getStatus() + expect(state.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('freezes the program while stopped', async () => { + const first = await readCounter() + await settle() + expect(await readCounter()).toBe(first) + }, 30000) + + it('de-energises the located output while stopped', async () => { + expect(await readPulse()).toBe(0) + }, 30000) + + it('re-initialised the program on the STOP edge', async () => { + // counter is declared `INT := 0` and increments every scan, so a value of + // 0 while stopped can only come from the STOP-edge re-init. + expect(await readCounter()).toBe(0) + }, 30000) + + it('run/stop command RUNs it again, from cycle 1', async () => { + const res = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(res.success).toBe(true) + expect(res.refusedBySwitch).toBeFalsy() + await settle() + + const state = await client.getStatus() + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + + // Counting resumed, and the output is driven again. + const first = await readCounter() + await settle() + expect(await readCounter()).not.toBe(first) + expect(await readPulse()).toBe(1) + }, 30000) + + it('reading the status never changes state', async () => { + const before = await client.getStatus() + const after = await client.getStatus() + expect(after.plcState).toBe(before.plcState) + expect(after.switchPosition).toBe(before.switchPosition) + }, 30000) + + it('keeps the debug channel alive across a stop/start cycle', async () => { + await client.setPlcState(PlcRuntimeState.STOPPED) + await settle() + // FC 0x45 must still answer while stopped -- the control channel IS the + // Modbus link, so it cannot depend on the PLC running. + const md5 = await client.getMd5Hash() + expect(md5.md5).toMatch(/^[0-9a-f]{32}$/) + await client.setPlcState(PlcRuntimeState.RUNNING) + await settle() + }, 60000) +}) + +describeIfSwitch('Hardware mode switch (HAL override, FC 0x4b + 0x46 over avr8js)', () => { + let sim: SimulatorModule + let client: ModbusRtuClient + + beforeAll(async () => { + sim = new SimulatorModule() + sim.loadAndRun(fs.readFileSync(path.resolve(SWITCH_HEX_PATH), 'utf-8')) + client = new ModbusRtuClient({ slaveId: 1, timeout: 5000, serialPort: new VirtualSerialPort(sim) }) + // connect() already waits 2.5s for setup(); the override reads STOP until + // 3s of firmware uptime, so the first assertions land inside the STOP + // window. + await client.connect() + }, 120000) + + afterAll(() => { + client?.disconnect() + sim?.stop() + }) + + it('boots STOPPED when the switch reads STOP, and reports the position', async () => { + const state = await client.getStatus() + expect(state.success).toBe(true) + expect(state.switchPosition).toBe(PlcSwitchPosition.STOP) + expect(state.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('refuses a RUN request while the switch reads STOP', async () => { + const res = await client.setPlcState(PlcRuntimeState.RUNNING) + // Refused, not queued: success is false and the reason is specific enough + // for the editor to tell the user to flip the switch. + expect(res.success).toBe(false) + expect(res.refusedBySwitch).toBe(true) + expect(res.state).toBe(PlcRuntimeState.STOPPED) + expect(res.switchPosition).toBe(PlcSwitchPosition.STOP) + + // Still stopped afterwards -- the refusal did not leave a pending start. + const after = await client.getStatus() + expect(after.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('runs by itself on the STOP -> RUN rising edge, with no command sent', async () => { + // Wait past the override's 3s flip point. Nothing is sent to the target in + // between: the transition must come from the switch alone (rule 3). + await new Promise((resolve) => setTimeout(resolve, 2000)) + + const state = await client.getStatus() + expect(state.switchPosition).toBe(PlcSwitchPosition.RUN) + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + }, 30000) + + it('accepts software stop and start once the switch reads RUN', async () => { + const stopped = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(stopped.success).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect((await client.getStatus()).plcState).toBe(PlcRuntimeState.STOPPED) + + const started = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(started.success).toBe(true) + expect(started.refusedBySwitch).toBeFalsy() + await new Promise((resolve) => setTimeout(resolve, 300)) + expect((await client.getStatus()).plcState).toBe(PlcRuntimeState.RUNNING) + }, 30000) +}) diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts index 8d712af56..37d3c19f5 100644 --- a/src/backend/shared/simulator/modbus-rtu-client.ts +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -1,7 +1,8 @@ -import type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { buildPlcSetStateRequest, parsePlcSetStateResponse } from '@root/backend/shared/debug/modbus-pdu' +import type { DebugStatusResult, Md5ProbeResult, PlcControlResult } from '@root/backend/shared/debug/types' import { detectTargetEndian } from '@root/frontend/utils/endian' -import { ModbusDebugResponse, ModbusFunctionCode } from './types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from './types' export interface SerialPortLike { isOpen: boolean @@ -469,13 +470,7 @@ export class ModbusRtuClient { // prepends: slaveId@6, FC@7, status@8, payload@9+. // ------------------------------------------------------------------------- - async getStatus(): Promise<{ - success: boolean - running?: boolean - tick?: number - uptimeMs?: number - error?: string - }> { + async getStatus(): Promise { try { const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_STATUS, allocBytes(0)) const response = await this.sendRequest(request) @@ -500,8 +495,13 @@ export class ModbusRtuClient { return { success: true, running: readUint8(response, 9) !== 0, + // Same byte as `running`, as the tri-state the run/stop machine has. + plcState: readUint8(response, 9), tick: readUint32BE(response, 10), uptimeMs: readUint32BE(response, 14), + // Appended by firmware carrying the run/stop state machine; absent on + // older firmware, which callers read as "no switch gating". + ...(response.length >= 19 ? { switchPosition: readUint8(response, 18) } : {}), } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -568,4 +568,28 @@ export class ModbusRtuClient { return { success: false, error: error instanceof Error ? error.message : String(error) } } } + + /** + * FC 0x4b -- ask the runtime to run or stop. + * + * Command only: reading the state is `getStatus()` (FC 0x46), which already + * reports it. A RUN request is refused (not queued) while the mode switch + * reads STOP; `refusedBySwitch` says so. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + try { + // buildPlcSetStateRequest returns [FC][state]; assembleRequest writes the + // FC + slaveId itself, so hand it only the trailing payload. + const pdu = buildPlcSetStateRequest(state) + const response = await this.sendRequest( + this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, pdu.subarray(1)), + ) + if (response.length < 8) { + return { success: false, error: `Invalid response: too short (${response.length} bytes)` } + } + return parsePlcSetStateResponse(response.subarray(7)) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } } diff --git a/src/backend/shared/simulator/types.ts b/src/backend/shared/simulator/types.ts index 006f4651a..e6609f8f8 100644 --- a/src/backend/shared/simulator/types.ts +++ b/src/backend/shared/simulator/types.ts @@ -7,10 +7,32 @@ export enum ModbusFunctionCode { DEBUG_GET_STATUS = 0x46, DEBUG_GET_VERSION = 0x47, DEBUG_GET_BOARD_ID = 0x48, + /** Set the runtime run/stop state. Reads go through DEBUG_GET_STATUS (0x46), + * which already reports the state — there is deliberately no second FC for + * querying it. */ + PLC_SET_STATE = 0x4b, } export enum ModbusDebugResponse { SUCCESS = 0x7e, ERROR_OUT_OF_BOUNDS = 0x81, ERROR_OUT_OF_MEMORY = 0x82, + /** PLC_SET_STATE only: a RUN request was refused because the hardware mode + * switch reads STOP. */ + REFUSED_BY_SWITCH = 0x86, +} + +/** Runtime states reported by DEBUG_GET_STATUS and PLC_SET_STATE (and by + * Runtime v4's `/api/status`). */ +export enum PlcRuntimeState { + STOPPED = 0, + RUNNING = 1, + ERROR = 2, +} + +/** Mode-switch positions. Boards with no physical switch always report RUN, so + * callers need no "absent" case. */ +export enum PlcSwitchPosition { + STOP = 0, + RUN = 1, } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index ca74ca98e..d4b8c9f15 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -1,22 +1,27 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ +import * as Popover from '@radix-ui/react-popover' import type { TimingStats } from '@root/middleware/shared/ports/types' import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' +import { Copy } from 'lucide-react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifier' import { MinusIcon } from '../../../../../../assets/icons/interface/Minus' import { PlusIcon } from '../../../../../../assets/icons/interface/Plus' import { RefreshIcon } from '../../../../../../assets/icons/interface/Refresh' +import { useDeviceConnect } from '../../../../../../hooks/use-device-connect' import { boardSelectors, pinSelectors } from '../../../../../../hooks/use-store-selectors' import { useOpenPLCStore } from '../../../../../../store' import type { RuntimeConnection } from '../../../../../../store/slices/device/types' import { cn } from '../../../../../../utils/cn' import { isOpenPLCRuntimeTarget, isSimulatorTarget, validateRuntimeVersion } from '../../../../../../utils/device' +import { serialPortDisplay } from '../../../../../../utils/serial-port-label' import { DropdownSearchInput } from '../../../../../_atoms/dropdown-search-input' import { Label } from '../../../../../_atoms/label' import { Select, SelectContent, SelectItem, SelectTrigger } from '../../../../../_atoms/select' import TableActions from '../../../../../_atoms/table-actions' +import { DeviceConnectButton } from '../../../../../_molecules/device-connect-button' import { EtherCATStats } from '../../../../../_molecules/ethercat-stats' import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '../../../../../_molecules/modal' import { PluginStatsPanel } from '../../../../../_molecules/plugin-stats-panel' @@ -24,6 +29,17 @@ import { ScanCycleStats } from '../../../../../_molecules/scan-cycle-stats' import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-editor-slot' import { PinMappingTable } from './components/pin-mapping-table' +/** + * Confirms the held device link on the device screen: a quiet, monochrome line + * that appears once Connect has settled on a channel a firmware answered. + */ +function DeviceConnectedIndicator({ isConnected }: { isConnected: boolean }) { + if (!isConnected) return null + return ( + Connected + ) +} + const Board = memo(function () { const capabilities = useCapabilities() const device = useDevice() @@ -50,12 +66,32 @@ const Board = memo(function () { const currentBoardInfo = availableBoards.get(deviceBoard) + // CONNECT flow (D72): open the device channel, classify it, and drive the + // flash follow-up when nothing answered the debug protocol. + const { + connect: connectDevice, + disconnect: disconnectDevice, + isConnected, + status: serialStatus, + } = useDeviceConnect(currentBoardInfo) + // Whether this target exposes the GPIO pin-mapping table. Arduino boards // enable it via their preset; runtime-v4 GPIO boards (e.g. the Raspberry // Pi HAL) opt in with `capabilities.pinMapping` in their VPP manifest. const pinMappingEnabled = resolveTargetCapabilities(currentBoardInfo).pinMapping const runtimeIpAddress = useOpenPLCStore((state) => state.deviceDefinitions.configuration.runtimeIpAddress || '') + // Read from the same place the connection resolver reads it, so the button and + // the resolution never disagree about whether a network path exists. + const modbusTcpConfigured = useOpenPLCStore( + (state) => + ( + (state.deviceDefinitions.configuration.vendorScreenData ?? {}) as Record< + string, + Record | undefined + > + )['modbus_tcp']?.['enabled'] === true, + ) const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const setRuntimeIpAddress = useOpenPLCStore((state) => state.deviceActions.setRuntimeIpAddress) const setRuntimeConnectionStatus = useOpenPLCStore((state) => state.deviceActions.setRuntimeConnectionStatus) @@ -349,6 +385,9 @@ const Board = memo(function () { setRuntimeJwtToken(null) setRuntimeConnectionStatus('disconnected') await runtime.clearCredentials() + // The session goes with it: control was this REST connection, and any debug + // channel opened off it has nothing left to belong to. + await device.closeRuntimeSession?.() return } @@ -593,33 +632,24 @@ const Board = memo(function () { Search
-
- + {connectionStatus === 'connected' && ( -
- ● Connected + <> {plcStatus && ( | PLC: {plcStatus} )} -
- )} - {connectionStatus === 'error' && ( - ● Connection failed + + )} -
+ ) : capabilities.hasLocalSerialPorts ? ( + <>
+ + + + ) : null} {!isOpenPLCRuntimeTarget(currentBoardInfo) && !isSimulatorTarget(currentBoardInfo) && (
diff --git a/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx new file mode 100644 index 000000000..6fc9bdc76 --- /dev/null +++ b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx @@ -0,0 +1,78 @@ +/** + * One Connect button serves both target families. These pin the behaviour that + * had drifted between the two hand-written copies: the label, when the button is + * disabled, and whether a connection is confirmed on screen at all. + */ +import { fireEvent, render, screen } from '@testing-library/react' + +import { DeviceConnectButton } from '../index' + +describe('DeviceConnectButton', () => { + it('reads Connect when disconnected and calls onConnect', () => { + const onConnect = jest.fn() + const onDisconnect = jest.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + expect(onConnect).toHaveBeenCalledTimes(1) + expect(onDisconnect).not.toHaveBeenCalled() + }) + + it('reads Disconnect when connected and calls onDisconnect', () => { + const onConnect = jest.fn() + const onDisconnect = jest.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + expect(onDisconnect).toHaveBeenCalledTimes(1) + expect(onConnect).not.toHaveBeenCalled() + }) + + it('confirms a live connection on screen', () => { + // The baremetal copy never showed this, so a connected device looked the same + // as a disconnected one apart from the button label. + render() + expect(screen.getByText('● Connected')).not.toBeNull() + }) + + it('reports a failed attempt', () => { + render() + expect(screen.getByText('● Connection failed')).not.toBeNull() + }) + + it('is inert while connecting', () => { + render() + expect((screen.getByRole('button', { name: 'Connecting...' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('explains itself when something blocks connecting', () => { + render( + , + ) + + const button = screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + expect(button.title).toBe('Select a communication port first') + }) + + it('stays live when nothing blocks it, so resolution can report the real reason', () => { + render() + expect((screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement).disabled).toBe(false) + }) + + it('renders caller-supplied detail beside the status', () => { + render( + + | PLC: RUNNING + , + ) + expect(screen.getByText('| PLC: RUNNING')).not.toBeNull() + }) +}) diff --git a/src/frontend/components/_molecules/device-connect-button/index.tsx b/src/frontend/components/_molecules/device-connect-button/index.tsx new file mode 100644 index 000000000..796c59ac4 --- /dev/null +++ b/src/frontend/components/_molecules/device-connect-button/index.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react' + +import type { ConnectionStatus } from '../../../store/slices/device/types' +import { cn } from '../../../utils/cn' + +type DeviceConnectButtonProps = { + /** Live connection state. Both target families use the same four states. */ + status: ConnectionStatus + /** Establish the connection. Called only when not already connected. */ + onConnect: () => void + /** Tear the connection down. Called only when connected. */ + onDisconnect: () => void + /** + * When set, the button is disabled and this says why (also the tooltip) — e.g. + * no communication port has been picked yet. + */ + blockedReason?: string + /** Detail shown beside the status: PLC state, license badge. */ + children?: ReactNode + /** DOM id kept for the existing onboarding/tour anchors. */ + containerId?: string +} + +/** + * Connect / Disconnect, for every target type. + * + * One component on purpose. A Runtime v4 target and a baremetal target are + * connected in completely different ways — REST login versus a held Modbus link — + * but to the user it is the same action in the same place, and it had drifted: the + * two buttons differed in colour when connected, in when they were disabled, and + * one of them never showed the green "Connected" confirmation at all. Those are + * the kind of differences nobody decides on; they accumulate. The connection + * mechanics stay with each caller, and only the appearance lives here. + */ +const DeviceConnectButton = ({ + status, + onConnect, + onDisconnect, + blockedReason, + children, + containerId, +}: DeviceConnectButtonProps) => { + const isConnected = status === 'connected' + const isConnecting = status === 'connecting' + const disabled = isConnecting || blockedReason !== undefined + + return ( +
+ + + {isConnected && ● Connected} + {status === 'error' && ● Connection failed} + {children} +
+ ) +} + +export { DeviceConnectButton } diff --git a/src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx b/src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx index 15743c701..a9826a62b 100644 --- a/src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx +++ b/src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx @@ -20,6 +20,7 @@ const ConfirmDeviceSwitchModal = () => { deviceActions.setRuntimeJwtToken(null) deviceActions.setRuntimeConnectionStatus('disconnected') deviceActions.setPlcRuntimeStatus(null) + deviceActions.setPlcSwitchPosition(null) if (modalData.onConfirm) { modalData.onConfirm() diff --git a/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx b/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx index 472af5cb5..8297151b4 100644 --- a/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx @@ -6,7 +6,11 @@ const RuntimeConnectionLostModal = () => { const { modals, modalActions } = useOpenPLCStore() const isOpen = modals['runtime-connection-lost']?.open || false - const modalData = modals['runtime-connection-lost']?.data as { label?: string } | undefined + // `body` lets a caller state WHICH link died and what to do about it, while the + // default keeps the Runtime v4 copy this modal was written for. The serial link + // (baremetal Connect) reuses the same dialog rather than cloning it — the shape + // of the news is identical: a held connection is gone after retries failed. + const modalData = modals['runtime-connection-lost']?.data as { label?: string; body?: string } | undefined const label = modalData?.label ?? 'Unknown' const handleClose = () => { @@ -32,8 +36,12 @@ const RuntimeConnectionLostModal = () => { Connection to runtime lost

- The connection to {label} has been lost after multiple failed attempts. Please check that - the runtime is running and accessible. + {modalData?.body ?? ( + <> + The connection to {label} has been lost after multiple failed attempts. Please check + that the runtime is running and accessible. + + )}

diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index a11b96cda..43c62094c 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -1,17 +1,13 @@ import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { useCallback, useEffect, useRef, useState } from 'react' -import { - type DebugResolverContext, - type DebugSpec, - resolveDebugConnection, -} from '../../../../backend/shared/hardware/debug-spec' -import type { DebugConnectionConfig } from '../../../../middleware/shared/ports/types' +import { resolveDeviceLinkCandidates } from '../../../../backend/shared/hardware/debug-spec' import { projectCapabilities } from '../../../../middleware/shared/ports/types' import { useCapabilities, useCompiler, useDebugger, + useDevice, useProject, useRuntime, useSimulator, @@ -19,11 +15,14 @@ import { import { StopIcon } from '../../../assets/icons/interface/Stop' import { useDebugPolling } from '../../../hooks/useDebugPolling' import { useDebugSession } from '../../../hooks/useDebugSession' +import { buildDeviceResolverContext, showDeviceDialog } from '../../../services/device-link-resolution' import { executeSaveProject } from '../../../services/save-actions' import { useOpenPLCStore } from '../../../store' import type { RuntimeConnection } from '../../../store/slices/device/types' import { cn } from '../../../utils/cn' import { logCompilerEvent } from '../../../utils/debugger-session' +import { isOpenPLCRuntimeTarget } from '../../../utils/device' +import { onDeviceFlashRequest } from '../../../utils/device-connect-events' import { getErrorMessage } from '../../../utils/get-error-message' import { type BuildOption, BuildOptionsPopover } from '../../_features/[workspace]/build-options' import { ChatButton } from '../../_molecules/workspace-activity-bar/default/chat' @@ -33,37 +32,6 @@ import { SearchButton } from '../../_molecules/workspace-activity-bar/default/se import { ZoomButton } from '../../_molecules/workspace-activity-bar/default/zoom' import { TooltipSidebarWrapperButton } from '../../_molecules/workspace-activity-bar/tooltip-button' -const showDebuggerMessage = ( - type: 'info' | 'warning' | 'error' | 'question', - title: string, - message: string, - buttons: string[], - options?: { primaryButtonIndex?: number; dismissButtonIndex?: number }, -): Promise => { - return new Promise((resolve) => { - useOpenPLCStore.getState().modalActions.openModal('debugger-message', { - type, - title, - message, - buttons, - ...options, - onResponse: (buttonIndex: number) => resolve(buttonIndex), - }) - }) -} - -const showDebuggerIpInput = (title: string, message: string, defaultValue: string): Promise => { - return new Promise((resolve) => { - useOpenPLCStore.getState().modalActions.openModal('debugger-ip-input', { - title, - message, - defaultValue, - onSubmit: (value: string) => resolve(value), - onCancel: () => resolve(null), - }) - }) -} - const disabledButtonClass = 'cursor-not-allowed opacity-50 [&>*:first-child]:hover:bg-transparent' type DefaultWorkspaceActivityBarProps = { @@ -91,6 +59,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const runtime = useRuntime() const simulator = useSimulator() const debuggerPort = useDebugger() + const device = useDevice() const projectPort = useProject() const capabilities = useCapabilities() const debugSession = useDebugSession() @@ -100,9 +69,16 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const [isDebuggerProcessing, setIsDebuggerProcessing] = useState(false) const [simulatorRunning, setSimulatorRunning] = useState(false) const pendingSimulatorDebugRef = useRef(false) + // True while a debug session is running OVER THE DEVICE CONNECTION (a baremetal + // target, whatever transport that connection uses). Such a session shares the + // connection, so it has to end when the connection does — which the drop handler + // below acts on. A runtime or simulator session owns its own channel and is + // unaffected, so this stays false for them. + const debugSessionRidesDeviceRef = useRef(false) const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const plcStatus = useOpenPLCStore((state): RuntimeConnection['plcStatus'] => state.runtimeConnection.plcStatus) + const switchPosition = useOpenPLCStore((state) => state.runtimeConnection.switchPosition) const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const canEdit = useOpenPLCStore((state) => state.workspace.canEdit) @@ -110,18 +86,60 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const currentBoardInfo = availableBoards.get(deviceDefinitions.configuration.deviceBoard) const isSimulatorBoard = resolveTargetCapabilities(currentBoardInfo).isInProcessSimulator - // Sync simulatorRunning when the simulator stops externally + const deviceConnectionStatus = useOpenPLCStore((state) => state.deviceConnection.status) + + // Run/stop travels over the session's control channel, so the button is live + // exactly when a SESSION exists — one question, asked once, for every target type. + // Every session publishes its status: a device connection, a runtime login, a + // running simulator. Asking the target's kind first (`directUsbUpload ? … : …`) + // meant asking "did the user log in" for a runtime, which is not the same question + // and diverged in practice: logged in, session never opened, every command + // refused. "Can a payload be delivered?" is what the button actually needs. + // + // Deliberately NOT applied to Build & Upload. Uploading is how a blank board stops + // being blank, so it cannot require a connection — see `handleBuild`, where the + // connection is consulted only to hand the serial port over to arduino-cli. + const plcControlBlocked = deviceConnectionStatus !== 'connected' + const plcControlBlockedReason = 'Connect to the target first' + + // The emulator stopping is a session ending, and a debug session riding it ends + // with it — which the drop handler below already does for every target. This + // only mirrors the emulator's own state into the button. useEffect(() => { const unsub = simulator.onStopped(() => { pendingSimulatorDebugRef.current = false setSimulatorRunning(false) - const { workspace } = useOpenPLCStore.getState() - if (workspace.isDebuggerVisible) { - void debugSession.stopSession() - } }) return unsub - }, [simulator, debugSession]) + }, [simulator]) + + // A serial debug session lives on the device connection: it shares that + // client, so when the link drops (unplug, reset, liveness failure, or the user + // pressing Disconnect) the session has no transport left and must end. Leaving + // it "active" would show a frozen variable table over a dead port and leave the + // debugger unable to reconnect. + // + // Modbus TCP sessions are deliberately untouched — they own their own socket + // and never depended on the serial link. + // Only 'connected' is tolerated. 'connecting' covers RECOVERY too (the + // connection died and the main process is reopening it), and by then the client + // the session was sharing is already closed — waiting for the recovery verdict + // would just keep a dead session on screen for the whole retry window. A session + // can only have started from 'connected', so the initial connect's 'connecting' + // never reaches this: no session is active to stop. + useEffect(() => { + if (deviceConnectionStatus === 'connected') return + if (!debugSessionRidesDeviceRef.current) return + if (!useOpenPLCStore.getState().workspace.isDebuggerVisible) return + + addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: 'Device disconnected — stopping the debug session (serial debugging runs over the device connection).', + }) + debugSessionRidesDeviceRef.current = false + void debugSession.stopSession() + }, [deviceConnectionStatus, debugSession, addLog]) // Stop simulator if the board is switched away while it's running const prevIsSimulatorBoardRef = useRef(isSimulatorBoard) @@ -204,7 +222,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const requiresRuntimeConnection = !resolveTargetCapabilities(boardInfo).directUsbUpload const { connectionStatus: connStatus, plcStatus: runStatus } = state.runtimeConnection if (requiresRuntimeConnection && connStatus === 'connected' && runStatus === 'RUNNING') { - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'warning', 'Stop PLC', 'The PLC must be stopped before continuing.', @@ -218,7 +236,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa setIsCompiling(false) return } - const stopResult = await runtime.stopPlc() + // Same unified control path as the Start/Stop button: the session routes + // it, so this works for a runtime and a device alike. + const stopResult = (await debuggerPort.setPlcState?.('STOPPED')) ?? { + success: false, + error: 'This target does not support run/stop control', + } if (!stopResult.success) { addLog({ id: crypto.randomUUID(), @@ -242,6 +265,26 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // aliases. const freshProjectData = useOpenPLCStore.getState().projectActions.getCompileReadyProjectData() + // Serial handoff (D72): a held device connection owns the serial port that + // arduino-cli needs for a direct-USB upload. Release it before the build so + // the upload can take the port; reconnect afterwards (auto-reconnect). + const caps = resolveTargetCapabilities(currentBoardInfo) + const willUpload = !isSimulatorBoard && !(overrides?.compileOnly ?? false) && caps.directUsbUpload + // Release ONLY if the held connection is the serial one arduino-cli needs. + // A connection over Modbus TCP is untouched, so debugging and run/stop keep + // working across the upload; disconnecting unconditionally used to throw it + // away. `released` also tells us whether to reconnect afterwards. + let serialWasReleased = false + if (willUpload && useOpenPLCStore.getState().deviceConnection.status === 'connected') { + try { + serialWasReleased = await device.releaseSerialPort( + useOpenPLCStore.getState().deviceDefinitions.configuration.communicationPort ?? null, + ) + } catch { + // best-effort: never block a build on the handoff. + } + } + try { // Track whether the compile stream already surfaced an error so we // don't log a second, generic "Compilation failed" after a failed @@ -290,13 +333,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator is running.' }) if (pendingSimulatorDebugRef.current) { pendingSimulatorDebugRef.current = false - // Simulator's debug spec resolves to the trivial - // `{ connectionType: 'simulator' }` config — see - // the hals.json entry. Pass it explicitly so the - // session's downstream MD5-verification path has - // the right transport instead of falling back to - // `connectAndStart`'s internal default. - void debugSession.connectAndStart({ connectionType: 'simulator', connectionParams: {} }) + // Rides the emulator's session, so it ends when the emulator + // does — through the same handler a pulled cable goes through. + debugSessionRidesDeviceRef.current = true + // No config: starting the emulator opened its session, so the + // connection manager already knows how to reach it. + void debugSession.connectAndStart() } } else { pendingSimulatorDebugRef.current = false @@ -314,6 +356,32 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (!result.success && !streamedError) { addLog({ id: crypto.randomUUID(), level: 'error', message: result.error ?? 'Compilation failed' }) } + + // Serial handoff (D72): if we released a held device connection for this + // upload, reconnect it now that arduino-cli is done with the port. + // Silent (no dialogs) — the user just flashed on purpose. + if (serialWasReleased && result.success) { + const boardTarget = deviceDefinitions.configuration.deviceBoard + const spec = currentBoardInfo?.debug + // Same candidate resolution Connect uses, so the link comes back the way + // the user established it. Only the serial link is ever released for an + // upload, but resolving the full list lets the reconnect land on Modbus + // TCP if that is what now answers. + // `deferPrompts`: this reconnect is silent and automatic (the user just + // flashed), so it must never pop an address dialog behind their back. A + // DHCP-only target simply stays disconnected until they press Connect. + const candidates = resolveDeviceLinkCandidates(spec, buildDeviceResolverContext(boardTarget), { + transports: caps.debuggerTransports, + deferPrompts: true, + }) + if (candidates.kind === 'candidates') { + try { + await window.bridge.deviceConnect(candidates.candidates.map((candidate) => candidate.config)) + } catch { + // best-effort: the user can press Connect again. + } + } + } } catch (err: unknown) { addLog({ id: crypto.randomUUID(), level: 'error', message: `Build error: ${getErrorMessage(err)}` }) } finally { @@ -322,9 +390,9 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }, [ compiler, - projectData, projectMeta, deviceDefinitions, + currentBoardInfo, isSimulatorBoard, simulator, debugSession, @@ -341,6 +409,15 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const handleBuildRef = useRef(handleBuild) handleBuildRef.current = handleBuild + // CONNECT flow (D72): the device screen's "No Firmware Detected" dialog lives + // in board.tsx but Build & Upload lives here. When the user chooses to flash, + // that dialog fires a decoupled event we answer by running the same build. + useEffect(() => { + return onDeviceFlashRequest(() => { + void handleBuildRef.current() + }) + }, []) + // --------------------------------------------------------------------------- // Build Library (.stlib) // --------------------------------------------------------------------------- @@ -423,46 +500,111 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // PLC control (Start/Stop for runtime targets) // --------------------------------------------------------------------------- - const handlePlcControl = useCallback(async (): Promise => { - if (!jwtToken || connectionStatus !== 'connected') return + /** + * Tell the user the hardware mode switch is holding the device in STOP. + * + * Shown both when the editor blocks a start locally (pre-check) and when the + * device refuses one, so the two paths read identically. `switchLabel` comes + * from the VPP manifest's optional `stateControl.modeSwitch.label` when the + * package provides it, so a P1AM says "CPU switch" rather than the generic + * wording. + */ + const warnSwitchInStop = useCallback( + async (deviceName: string, switchLabel?: string): Promise => { + await showDeviceDialog( + 'warning', + 'Device is in STOP', + `The ${switchLabel ?? 'mode switch'} on ${deviceName} is in the STOP position. ` + + 'The PLC cannot be started from the editor while the switch is in STOP.\n\n' + + 'Flip the switch to RUN and try again.', + ['OK'], + ) + }, + [], + ) + const handlePlcControl = useCallback(async (): Promise => { + const boardTarget = deviceDefinitions.configuration.deviceBoard + const boardInfo = availableBoards.get(boardTarget) + const caps = resolveTargetCapabilities(boardInfo) + if (!caps.plcStateControl) return + + const switchLabel = ( + boardInfo as { stateControl?: { modeSwitch?: { label?: string } } } | undefined + )?.stateControl?.modeSwitch?.label + + // ONE path for every target. "Start the PLC" is the same request whether it + // travels as Modbus FC 0x4b down a cable or as an HTTP POST to a runtime; the + // connection manager routes it over whatever the session's control channel is. + // Branching here on target type is what kept two copies of the switch + // pre-check, the refusal handling and the error reporting in step by hand. + // + // Reads are NOT done here: the session's status poll keeps `plcStatus` and + // `switchPosition` in the store, so the pre-check is a store lookup rather than + // another round trip over a medium the poll is already using. try { - if (plcStatus === 'RUNNING') { - const result = await runtime.stopPlc() - if (!result.success) { - addLog({ - id: crypto.randomUUID(), - level: 'error', - message: `Failed to stop PLC: ${result.error ?? 'Unknown error'}`, - }) - return - } - } else { - const result = await runtime.startPlc() - if (!result.success) { - addLog({ - id: crypto.randomUUID(), - level: 'error', - message: `Failed to start PLC: ${result.error ?? 'Unknown error'}`, - }) - return - } + const wantRun = plcStatus !== 'RUNNING' + + // Never send a start to a device whose switch reads STOP. `null` means + // "unknown / no switch", which must NOT block: a board with no physical + // switch, or firmware predating the state machine, would otherwise be + // un-startable. + if (wantRun && switchPosition === 'stop') { + await warnSwitchInStop(boardTarget, switchLabel) + return } - const statusResult = await runtime.getStatus() - if (statusResult.success && statusResult.status) { + const result = await debuggerPort.setPlcState?.(wantRun ? 'RUNNING' : 'STOPPED') + if (!result) return + + if (result.unsupported) { + addLog({ + id: crypto.randomUUID(), + level: 'info', + message: 'This firmware predates run/stop control. Rebuild and upload the program to enable Start/Stop.', + }) + return + } + // Covers the race where the switch moved between the store's last poll and + // this command: the device is authoritative, so its refusal wins. + if (result.refusedBySwitch) { + await warnSwitchInStop(boardTarget, switchLabel) + return + } + if (!result.success) { + addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Failed to ${wantRun ? 'start' : 'stop'} PLC: ${result.error ?? 'Unknown error'}`, + }) + return + } + + // No re-read: the target settles into the new state on its next scan and the + // status poll picks it up within one tick. Reflecting the acknowledgement + // keeps the button responsive without a round trip that could still read the + // pre-change value. + if (result.state !== undefined) { useOpenPLCStore .getState() - .deviceActions.setPlcRuntimeStatus(statusResult.status as NonNullable) + .deviceActions.setPlcRuntimeStatus( + (result.state === 1 ? 'RUNNING' : result.state === 2 ? 'ERROR' : 'STOPPED') as NonNullable< + RuntimeConnection['plcStatus'] + >, + ) } } catch (error: unknown) { addLog({ id: crypto.randomUUID(), level: 'error', message: `PLC control error: ${getErrorMessage(error)}` }) } - }, [runtime, jwtToken, connectionStatus, plcStatus, addLog]) - - // --------------------------------------------------------------------------- - // Simulator control (Start/Stop simulator + auto-debug) - // --------------------------------------------------------------------------- + }, [ + deviceDefinitions.configuration.deviceBoard, + availableBoards, + plcStatus, + switchPosition, + debuggerPort, + addLog, + warnSwitchInStop, + ]) const handleSimulatorControl = useCallback(async (): Promise => { try { @@ -486,18 +628,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // MD5 verification — runs after debug compilation for non-simulator // --------------------------------------------------------------------------- - const handleMd5Verification = async ( - projectPath: string, - boardTarget: string, - debugConfig: DebugConnectionConfig, - isRuntimeTarget: boolean, - ) => { + const handleMd5Verification = async (projectPath: string, boardTarget: string, isRuntimeTarget: boolean) => { const { consoleActions, runtimeConnection, deviceActions } = useOpenPLCStore.getState() try { // If runtime target + PLC stopped, offer to start if (isRuntimeTarget && runtimeConnection.plcStatus === 'STOPPED' && runtimeConnection.jwtToken) { - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'question', 'PLC Stopped', 'The PLC is currently stopped. The debugger requires the PLC to be running. Would you like to start the PLC now?', @@ -510,9 +647,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Starting PLC...' }) - const startResult = await runtime.startPlc() + const startResult = (await debuggerPort.setPlcState?.('RUNNING')) ?? { + success: false, + error: 'This target does not support run/stop control', + } if (!startResult.success) { - await showDebuggerMessage( + await showDeviceDialog( 'error', 'Start PLC Failed', `Could not start the PLC: ${startResult.error || 'Unknown error'}`, @@ -529,7 +669,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Verifying program MD5...' }) const md5Result = await debuggerPort.readProgramMd5(projectPath, boardTarget) if (!md5Result.success || !md5Result.md5) { - await showDebuggerMessage('error', 'MD5 Extraction Failed', md5Result.error ?? 'Could not extract MD5', ['OK']) + await showDeviceDialog('error', 'MD5 Extraction Failed', md5Result.error ?? 'Could not extract MD5', ['OK']) setIsDebuggerProcessing(false) return } @@ -537,22 +677,22 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Connect debug transport before MD5 verification — the web platform // needs an active transport (WebRTC or HTTP fallback) to query the device. // connect() is idempotent: connectAndStart will reuse this connection. - const preConnectResult = await debuggerPort.connect(debugConfig) + const preConnectResult = await debuggerPort.connect() if (!preConnectResult.success) { - await showDebuggerMessage( + await showDeviceDialog( 'error', - 'Connection Error', - `Could not connect to debug target: ${preConnectResult.error ?? 'Unknown error'}`, + "Can't Start Debugger", + `Can't start the debugger — ${preConnectResult.error ?? 'unknown error'}.`, ['OK'], ) setIsDebuggerProcessing(false) return } - const verifyResult = await debuggerPort.verifyMd5(md5Result.md5, debugConfig) + const verifyResult = await debuggerPort.verifyMd5(md5Result.md5) if (!verifyResult.success) { await debuggerPort.disconnect() - await showDebuggerMessage( + await showDeviceDialog( 'error', 'Connection Error', `Could not verify MD5: ${verifyResult.error ?? 'Unknown error'}`, @@ -564,17 +704,22 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (verifyResult.match) { consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'MD5 verified. Starting debugger...' }) - // Surface the active transport in the store so transport-specific - // pollers (useDebugPolling) can size their batches against the - // real frame budget rather than guessing from the board target. - useOpenPLCStore.getState().workspaceActions.setDebugConnectionType(debugConfig.connectionType) + // The debug poll sizes its batches to the frame budget, so it needs the + // medium the DEBUG channel rides — published by the manager and mirrored in + // the store. Reading the control medium instead left a v4 session (control + // over REST, debug over a WebSocket) with no medium at all, and it silently + // polled with TCP-sized batches: 60 variables per round trip instead of 500. + const activeTransport = useOpenPLCStore.getState().deviceConnection.debugTransport + if (activeTransport) { + useOpenPLCStore.getState().workspaceActions.setDebugConnectionType(activeTransport) + } // Persist the target's byte order — detected from the MD5 // response trailer in the runtime — so the swap layer at the // read / write boundaries flips on BE targets. Default to // `'le'` when the trailer was missing or malformed (older // runtimes); detectTargetEndian already logged a warning. useOpenPLCStore.getState().workspaceActions.setDebugTargetEndian(verifyResult.targetEndian ?? 'le') - await debugSession.connectAndStart(debugConfig) + await debugSession.connectAndStart() setIsDebuggerProcessing(false) } else { // Disconnect before re-upload; the recursive call will reconnect @@ -585,7 +730,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa level: 'warning', message: `MD5 mismatch. Target: ${verifyResult.targetMd5}, Expected: ${md5Result.md5}`, }) - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'warning', 'Program Mismatch', 'The program on the target does not match. Upload the current project?', @@ -615,7 +760,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa message: 'Upload completed. Re-verifying...', }) await new Promise((resolve) => setTimeout(resolve, 2000)) - void handleMd5Verification(projectPath, boardTarget, debugConfig, isRuntimeTarget) + void handleMd5Verification(projectPath, boardTarget, isRuntimeTarget) } else { consoleActions.addLog({ id: crypto.randomUUID(), @@ -639,111 +784,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } } - // --------------------------------------------------------------------------- - // Debug-spec resolver — surface picker / prompt / error dialogs and - // return a connection-ready DebugConnectionConfig, or null if the - // user cancelled or no config could be resolved. - // --------------------------------------------------------------------------- - - // Renderer-local prompt cache for the DHCP-IP-style flows. Keyed - // by `||` (or `builtin||` - // for hals.json entries) so two boards sharing a `cacheKey` value - // don't see each other's last-entered IP. Lives on a ref so it - // survives across re-renders without triggering them. - const promptCacheRef = useRef>>({}) - - const resolveDebugConfigWithUx = useCallback( - async (boardTarget: string, spec: DebugSpec | undefined): Promise => { - if (!spec) { - await showDebuggerMessage( - 'warning', - 'Debugging Not Available', - "This board hasn't declared a debug spec. The VPP package (or hals.json entry) must provide a `debug` block.", - ['OK'], - ) - return null - } - - // Build resolver context from current store state on each call — - // captures the user's freshest screen edits without forcing the - // user to save first. - const buildContext = (): DebugResolverContext => { - const store = useOpenPLCStore.getState() - const cfg = store.deviceDefinitions.configuration - const rtConn = store.runtimeConnection - // `vendorScreenData` is already keyed by section ID (e.g. - // `modbus_rtu`); resolver state's `screens` shape matches - // 1:1 so we pass it straight through. - const screens = (cfg.vendorScreenData ?? {}) as Record> - const cacheBucketKey = `${cfg.deviceBoard}` - const promptCache = promptCacheRef.current[cacheBucketKey] ?? {} - return { - state: { - configuration: { - deviceBoard: cfg.deviceBoard, - ...(cfg.communicationPort ? { communicationPort: cfg.communicationPort } : {}), - ...(cfg.runtimeIpAddress ? { runtimeIpAddress: cfg.runtimeIpAddress } : {}), - }, - screens, - runtimeConnection: { - ...(rtConn.connectionStatus ? { connectionStatus: rtConn.connectionStatus } : {}), - ...(rtConn.jwtToken ? { jwtToken: rtConn.jwtToken } : {}), - }, - promptCache, - }, - capabilities: { - runtimeConnected: runtime.isReadyForDebug?.() === true && rtConn.connectionStatus === 'connected', - jwtToken: Boolean(rtConn.jwtToken), - }, - } - } - - let selectedChannelIndex: number | undefined - // Loop: pickers/prompts re-invoke the resolver with extra state - // until it returns config or error/unsupported/cancelled. - // Capped at 8 iterations as a defensive guard against spec - // bugs that could otherwise loop forever. - for (let iteration = 0; iteration < 8; iteration += 1) { - const outcome = resolveDebugConnection(spec, buildContext(), selectedChannelIndex) - if (outcome.kind === 'config') { - return outcome.config - } - if (outcome.kind === 'error') { - await showDebuggerMessage('warning', outcome.title, outcome.body, ['OK']) - return null - } - if (outcome.kind === 'unsupported') { - // Defensive — buildContext already errored at top-level on - // missing spec, so we shouldn't reach here normally. - return null - } - if (outcome.kind === 'pick') { - const buttons = outcome.channels.map((c) => c.label) - const choice = await showDebuggerMessage('question', outcome.title, outcome.body, buttons) - if (choice < 0 || choice >= outcome.channels.length) return null - selectedChannelIndex = outcome.channels[choice].index - continue - } - if (outcome.kind === 'prompt') { - const bucketKey = boardTarget - const bucket = (promptCacheRef.current[bucketKey] ??= {}) - for (const field of outcome.fields) { - const previous = field.cacheKey ? bucket[field.cacheKey] : undefined - const result = await showDebuggerIpInput(field.title, field.message, previous ?? field.defaultValue ?? '') - if (result === null) return null - const trimmed = result.trim() - if (!trimmed) return null - if (field.cacheKey) bucket[field.cacheKey] = trimmed - } - selectedChannelIndex = outcome.channelIndex - continue - } - } - return null - }, - [runtime], - ) - // --------------------------------------------------------------------------- // Debugger click — full orchestration for non-simulator targets // --------------------------------------------------------------------------- @@ -759,11 +799,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Toggle off if (workspace.isDebuggerVisible) { + debugSessionRidesDeviceRef.current = false await debugSession.stopSession() return } if (isDebuggerProcessing) return + setIsDebuggerProcessing(true) try { @@ -785,8 +827,38 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const projectPath = project.meta.path const boardInfo = availableBoards.get(boardTarget) - const debugConfig = await resolveDebugConfigWithUx(boardTarget, boardInfo?.debug) - if (!debugConfig) { + // No resolution here at all. Every target's session is established before a + // debug session can start — a device by Connect, a runtime by logging in, the + // simulator by pressing Start — so the only question left is whether that + // session exists. Which medium it uses is the connection manager's to know. + const isRuntime = isOpenPLCRuntimeTarget(boardInfo) + + // A session the manager holds (a device or the simulator) also OWNS the debug + // channel, so the session ending ends the debug session — see the drop handler + // above. A runtime's debug channel is its own and outlives nothing. + debugSessionRidesDeviceRef.current = !isRuntime + + // One question for every target: does the manager hold a session? A simulator's + // session is its running emulator, a device's is Connect, a runtime's is the + // login — all three publish the same status. + const sessionStatus = useOpenPLCStore.getState().deviceConnection.status + addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `[connection] debug session requested for ${boardTarget}; session is "${sessionStatus}"`, + }) + + // Connect first. Starting a debug session must never establish the connection + // itself: connecting is the user's explicit action and reports what it found. + if (sessionStatus !== 'connected') { + await showDeviceDialog( + 'warning', + 'Connection Required', + isRuntime + ? 'Connect to the runtime first. The debugger runs over that connection, so it must be established before a debug session can start.' + : 'Connect to the device first. The debugger runs over the device connection, so the device must be connected before a debug session can start.', + ['OK'], + ) setIsDebuggerProcessing(false) return } @@ -810,12 +882,10 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa return } - // `isRuntimeTarget` here only gates the "PLC stopped, start it?" - // dialog inside MD5 verification. Tied to whether the active - // channel needs the runtime alive — websocket/tcp targets do, - // rtu/simulator targets don't. - const isRuntimeTarget = debugConfig.connectionType === 'websocket' || debugConfig.connectionType === 'tcp' - void handleMd5Verification(projectPath, boardTarget, debugConfig, isRuntimeTarget) + // Only gates the "PLC stopped, start it?" dialog inside MD5 verification, + // which applies to an OpenPLC runtime (v3/v4) — a fact about the TARGET, not + // about which transport happens to carry the session. + void handleMd5Verification(projectPath, boardTarget, isRuntime) } catch (error: unknown) { consoleActions.addLog({ id: crypto.randomUUID(), @@ -838,8 +908,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa canEdit, executeSave, addLog, - resolveDebugConfigWithUx, - ]) + currentBoardInfo]) // --------------------------------------------------------------------------- // JSX @@ -902,8 +971,8 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa ? simulatorRunning ? 'Stop Simulator' : 'Start Simulator' - : connectionStatus !== 'connected' - ? 'Connect to runtime first' + : plcControlBlocked + ? plcControlBlockedReason : plcStatus === 'RUNNING' ? 'Stop PLC' : 'Start PLC' @@ -911,13 +980,17 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa > void handleSimulatorControl() : () => void handlePlcControl()} - disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : connectionStatus !== 'connected'} + disabled={ + isSimulatorBoard + ? isCompiling || isDebuggerProcessing + : plcControlBlocked + } className={cn( isSimulatorBoard ? isCompiling || isDebuggerProcessing ? disabledButtonClass : '' - : connectionStatus !== 'connected' + : plcControlBlocked ? disabledButtonClass : '', )} diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index 1f696ed8f..ad740bc96 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -13,6 +13,7 @@ import { RuntimeCreateUserModal, RuntimeDiscoverDevicesModal, RuntimeLoginModal import { ConfirmDeleteProjectModal } from '../_organisms/modals/confirm-delete-project-modal' import { ConfirmInstallLibrariesModal } from '../_organisms/modals/confirm-install-libraries-modal' import { ConfirmPlcopenImportModal } from '../_organisms/modals/confirm-plcopen-import-modal' +import { DebuggerIpInputModal } from '../_organisms/modals/debugger-ip-input-modal' import { DebuggerMessageModal } from '../_organisms/modals/debugger-message-modal' import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' @@ -138,6 +139,7 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { )} {modals?.['runtime-connection-lost']?.open === true && } {modals?.['debugger-message']?.open === true && } + {modals?.['debugger-ip-input']?.open === true && } {modals?.['missing-libraries']?.open === true && } {modals?.['public-catalog-browser']?.open === true && } {modals?.['confirm-install-libraries']?.open === true && } diff --git a/src/frontend/hooks/__tests__/use-device-connect.test.ts b/src/frontend/hooks/__tests__/use-device-connect.test.ts new file mode 100644 index 000000000..33a5e2ef9 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-connect.test.ts @@ -0,0 +1,209 @@ +import { renderHook } from '@testing-library/react' + +// `mock*`-prefixed refs are hoisted into the jest.mock factories below. +const mockOpenModal = jest.fn() +const mockSetDeviceConnectionStatus = jest.fn() +const mockAddLog = jest.fn() + +const mockState: Record = { + deviceDefinitions: { configuration: { deviceBoard: 'Test Board', communicationPort: 'COM5', vendorScreenData: {} } }, + deviceConnection: { status: 'disconnected', port: null }, + runtimeConnection: { ipAddress: '192.168.0.128', jwtToken: 'jwt-tok' }, + modalActions: { openModal: mockOpenModal }, + consoleActions: { addLog: mockAddLog }, + deviceActions: { + setDeviceConnectionStatus: mockSetDeviceConnectionStatus, + }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +const mockConnect = jest.fn() +const mockDisconnect = jest.fn().mockResolvedValue({ success: true }) +const mockOnConnectionStatus = jest.fn().mockReturnValue(() => undefined) +const mockResolveDeviceLinkWithUx = jest.fn((..._args: unknown[]) => Promise.resolve(mockResolution)) +const mockRequestDeviceFlash = jest.fn() + +/** What the shared resolution returns: ordered ways to reach the device. */ +const serialCandidate = { + channelLabel: 'Modbus RTU', + channelIndex: 0, + config: { connectionType: 'rtu', connectionParams: { port: 'COM5', baudRate: 115200, slaveId: 1 } }, +} +/** Shape the hook consumes: what can be tried now, and what needs input first. */ +let mockResolution: unknown = { candidates: [serialCandidate], awaitingInput: [] } + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) +jest.mock('@root/middleware/shared/providers/platform-context', () => ({ + useDevice: () => ({ + connect: mockConnect, + disconnect: mockDisconnect, + onConnectionStatus: mockOnConnectionStatus, + }), +})) +jest.mock('../../services/device-link-resolution', () => ({ + resolveDeviceLinkWithUx: (...args: unknown[]) => mockResolveDeviceLinkWithUx(...args), +})) +jest.mock('../../utils/device-connect-events', () => ({ requestDeviceFlash: mockRequestDeviceFlash })) + +import type { BoardInfo } from '@root/middleware/shared/ports/types' + +import { useDeviceConnect } from '../use-device-connect' + +const board = { debug: {} } as unknown as BoardInfo + +function latestOnResponse(): (index: number) => void { + const [, props] = mockOpenModal.mock.calls[mockOpenModal.mock.calls.length - 1] + return (props as { onResponse: (i: number) => void }).onResponse +} + +beforeEach(() => { + jest.clearAllMocks() + mockState.deviceConnection = { status: 'disconnected', port: null } + mockState.runtimeConnection = { ipAddress: '192.168.0.128', jwtToken: 'jwt-tok' } + mockResolution = { candidates: [serialCandidate], awaitingInput: [] } + mockDisconnect.mockResolvedValue({ success: true }) + mockOnConnectionStatus.mockReturnValue(() => undefined) +}) + +describe('useDeviceConnect', () => { + // Mirroring pushed link status is NOT this hook's job: the link outlives the + // device screen, so that subscription lives in `useDeviceConnectionMonitor` + // (mounted at workspace level) and is tested there. + + const tcpCandidate = { + channelLabel: 'Modbus TCP', + channelIndex: 0, + config: { connectionType: 'tcp', connectionParams: { ipAddress: '192.168.0.50' } }, + } + + it('hands the connection EVERY resolved candidate, in order', async () => { + // Connect does not choose a transport: the main process tries the list and + // keeps the first that answers, which is what lets a stale Modbus TCP address + // fall through to the cable. Choosing here is what previously stranded a + // Modbus-TCP-only project on "select a communication port". + mockResolution = { candidates: [serialCandidate, tcpCandidate], awaitingInput: [] } + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).toHaveBeenCalledWith([serialCandidate.config, tcpCandidate.config]) + }) + + it('does nothing when resolution was cancelled or impossible', async () => { + // The shared resolution has already told the user why (a cancelled prompt is + // the user's answer), so this must not stack a second dialog on top. + mockResolution = null + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).not.toHaveBeenCalled() + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('never asks for a DHCP address when a silent candidate connects', async () => { + // The user's report: with DHCP on, Connect asked for an address before trying + // anything. With a cable attached that question is pure interruption, so the + // deferred channel must stay unasked when the cable works. + mockResolution = { candidates: [serialCandidate], awaitingInput: [1] } + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockResolveDeviceLinkWithUx).toHaveBeenCalledTimes(1) + expect(mockResolveDeviceLinkWithUx.mock.calls[0][2]).toMatchObject({ deferPrompts: true }) + expect(mockConnect).toHaveBeenCalledTimes(1) + }) + + it('asks for the deferred address only after the silent candidates fail', async () => { + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [serialCandidate], awaitingInput: [1] })) + .mockImplementationOnce(() => Promise.resolve({ candidates: [tcpCandidate], awaitingInput: [] })) + mockConnect + .mockResolvedValueOnce({ status: 'no-response' }) + .mockResolvedValueOnce({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + // Second resolve targets ONLY the channel that needed input. + expect(mockResolveDeviceLinkWithUx).toHaveBeenCalledTimes(2) + expect(mockResolveDeviceLinkWithUx.mock.calls[1][2]).toMatchObject({ onlyChannels: [1] }) + expect(mockConnect).toHaveBeenNthCalledWith(2, [tcpCandidate.config]) + // It connected on the second pass, so no failure dialog. + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('names every endpoint it tried when nothing answers', async () => { + mockResolution = { candidates: [serialCandidate, tcpCandidate], awaitingInput: [] } + mockConnect.mockResolvedValue({ status: 'no-response' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + const [, props] = mockOpenModal.mock.calls[0] + expect((props as { message: string }).message).toContain('192.168.0.50') + expect((props as { message: string }).message).toContain('COM5') + }) + + it('marks the link as connecting before handing the candidates over', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('connecting', null) + expect(mockConnect).toHaveBeenCalledWith([serialCandidate.config]) + }) + + it('opens no dialog when a firmware answered', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('shows a no-response error dialog', async () => { + mockConnect.mockResolvedValue({ status: 'no-response' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Response' }) + }) + + it('surfaces a connection error', async () => { + mockConnect.mockResolvedValue({ status: 'error', error: 'boom' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'Connection Error', message: 'boom' }) + }) + + it('offers to flash on no-firmware and requests a build when accepted', async () => { + mockConnect.mockResolvedValue({ status: 'no-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Firmware Detected' }) + latestOnResponse()(0) + expect(mockRequestDeviceFlash).toHaveBeenCalledTimes(1) + latestOnResponse()(1) + expect(mockRequestDeviceFlash).toHaveBeenCalledTimes(1) + }) + + it('disconnect closes the held link', async () => { + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.disconnect() + expect(mockDisconnect).toHaveBeenCalledTimes(1) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('disconnected', null) + }) + + it('derives isConnecting / isConnected from the store status', () => { + mockState.deviceConnection = { status: 'connected', port: 'COM5' } + const { result } = renderHook(() => useDeviceConnect(board)) + expect(result.current.isConnected).toBe(true) + expect(result.current.isConnecting).toBe(false) + expect(result.current.status).toBe('connected') + }) +}) diff --git a/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts b/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts new file mode 100644 index 000000000..0f7516fd7 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts @@ -0,0 +1,177 @@ +import { renderHook } from '@testing-library/react' + +// `mock*`-prefixed refs are hoisted into the jest.mock factories below. +const mockSetDeviceConnectionStatus = jest.fn() +const mockOpenModal = jest.fn() +const mockAddLog = jest.fn() + +const mockOpenRuntimeSession = jest.fn().mockResolvedValue({ success: true }) +const mockCloseRuntimeSession = jest.fn().mockResolvedValue({ success: true }) +const mockResolveRuntimeDebugChannel = jest.fn(() => null as unknown) + +const mockState: Record = { + modalActions: { openModal: mockOpenModal }, + consoleActions: { addLog: mockAddLog }, + runtimeConnection: { connectionStatus: 'disconnected', jwtToken: null, ipAddress: null }, + deviceDefinitions: { configuration: { deviceBoard: 'OpenPLC Runtime v4' } }, + deviceAvailableOptions: { availableBoards: new Map() }, + deviceActions: { + setDeviceConnectionStatus: mockSetDeviceConnectionStatus, + }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +const mockOnConnectionStatus = jest.fn().mockReturnValue(() => undefined) +const mockOnLinkLog = jest.fn().mockReturnValue(() => undefined) + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) +jest.mock('../../../middleware/shared/providers', () => ({ + useDevice: () => ({ + onConnectionStatus: mockOnConnectionStatus, + onLinkLog: mockOnLinkLog, + openRuntimeSession: mockOpenRuntimeSession, + closeRuntimeSession: mockCloseRuntimeSession, + }), +})) +jest.mock('../../services/device-link-resolution', () => ({ + resolveRuntimeDebugChannel: (...args: unknown[]) => mockResolveRuntimeDebugChannel(...(args as [])), +})) + +import { useDeviceConnectionMonitor } from '../use-device-connection-monitor' + +type Payload = { + status: string + descriptor?: string + transport?: 'rtu' | 'tcp' + debugTransport?: 'rtu' | 'tcp' | 'websocket' + reason?: 'lost' +} + +/** Mount the hook and hand back the main-process push callback. */ +function mountAndPush(): (payload: Payload) => void { + renderHook(() => useDeviceConnectionMonitor()) + return mockOnConnectionStatus.mock.calls[0][0] as (payload: Payload) => void +} + +beforeEach(() => { + jest.clearAllMocks() + mockOnConnectionStatus.mockReturnValue(() => undefined) + mockOnLinkLog.mockReturnValue(() => undefined) + mockResolveRuntimeDebugChannel.mockReturnValue(null) + mockState.runtimeConnection = { connectionStatus: 'disconnected', jwtToken: null, ipAddress: null } +}) + +describe('useDeviceConnectionMonitor', () => { + describe('runtime sessions', () => { + it('opens a session when a runtime login comes up', () => { + // A runtime is controlled over REST, which is connectionless — logging in IS + // what establishes its session. + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: 'jwt', ipAddress: '10.0.0.5' } + mockState.deviceAvailableOptions = { + availableBoards: new Map([['OpenPLC Runtime v4', { debug: { channels: [] } }]]), + } + const debugChannel = { connectionType: 'websocket', connectionParams: { ipAddress: '10.0.0.5' } } + mockResolveRuntimeDebugChannel.mockReturnValue(debugChannel) + + renderHook(() => useDeviceConnectionMonitor()) + + expect(mockOpenRuntimeSession).toHaveBeenCalledWith({ address: '10.0.0.5', debug: debugChannel }) + }) + + it('closes the session when the runtime connection goes down', () => { + renderHook(() => useDeviceConnectionMonitor()) + expect(mockCloseRuntimeSession).toHaveBeenCalledTimes(1) + expect(mockOpenRuntimeSession).not.toHaveBeenCalled() + }) + }) + + it('mirrors the main-process connection trace into the console', () => { + // The decisions worth reading happen in the main process; the console is where + // a user can actually see and copy them while reproducing a problem. + renderHook(() => useDeviceConnectionMonitor()) + const emit = mockOnLinkLog.mock.calls[0][0] as (message: string) => void + + emit('open: 2 candidate(s) in order: tcp 192.168.2.20, rtu /dev/ttyACM0') + + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ level: 'info', message: expect.stringContaining('tcp 192.168.2.20') }), + ) + }) + + it('subscribes once on mount and unsubscribes on unmount', () => { + const unsubscribe = jest.fn() + mockOnConnectionStatus.mockReturnValue(unsubscribe) + + const { unmount } = renderHook(() => useDeviceConnectionMonitor()) + expect(mockOnConnectionStatus).toHaveBeenCalledTimes(1) + + unmount() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('mirrors every pushed status into the store', () => { + const push = mountAndPush() + + for (const status of ['connecting', 'connected', 'disconnected', 'error'] as const) { + push({ status, descriptor: 'COM5', transport: 'rtu', debugTransport: 'rtu' }) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith(status, 'COM5', 'rtu', 'rtu') + } + }) + + it('mirrors a recovery attempt as connecting, with no transport claimed yet', () => { + const push = mountAndPush() + + push({ status: 'connecting', descriptor: 'COM5' }) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('connecting', 'COM5', null, null) + }) + + it('warns the user only when recovery gave up', () => { + const push = mountAndPush() + + // An 'error' from something the user just clicked already has its own dialog. + push({ status: 'error', descriptor: 'COM5' }) + expect(mockOpenModal).not.toHaveBeenCalled() + + push({ status: 'error', descriptor: 'COM5', reason: 'lost' }) + expect(mockOpenModal).toHaveBeenCalledTimes(1) + const [modalId, data] = mockOpenModal.mock.calls[0] + expect(modalId).toBe('runtime-connection-lost') + expect(data).toMatchObject({ label: 'COM5' }) + expect(String((data as { body: string }).body)).toContain('COM5') + }) + + it('does not warn while the link is merely reconnecting', () => { + // The whole point of recovery: a cable pulled and plugged back in must not + // interrupt the user with a dialog. + const push = mountAndPush() + + push({ status: 'connecting', descriptor: 'COM5' }) + push({ status: 'connected', descriptor: 'COM5' }) + + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('still names the device when the endpoint is unknown', () => { + const push = mountAndPush() + push({ status: 'error', reason: 'lost' }) + expect(mockOpenModal).toHaveBeenCalledWith('runtime-connection-lost', { + label: 'the device', + body: expect.stringContaining('the device'), + }) + }) + + it('advises the right thing to check for the transport that dropped', () => { + // "Check the cable" is useless advice for a link that ran over ethernet. + const push = mountAndPush() + push({ status: 'error', descriptor: '192.168.0.50', transport: 'tcp', reason: 'lost' }) + + const [, data] = mockOpenModal.mock.calls[0] + expect((data as { body: string }).body).toContain('192.168.0.50') + expect((data as { body: string }).body).toContain('network') + expect((data as { body: string }).body).not.toContain('cable') + }) +}) diff --git a/src/frontend/hooks/__tests__/use-device-plc-state.test.ts b/src/frontend/hooks/__tests__/use-device-plc-state.test.ts new file mode 100644 index 000000000..9d784b603 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-plc-state.test.ts @@ -0,0 +1,92 @@ +/** + * useDevicePlcState — mirrors the held device link's run/stop state into the + * store. The hook owns no timer; it only translates what the main process + * already pushes on each liveness tick. + */ +import { renderHook } from '@testing-library/react' + +const mockSetPlcRuntimeStatus = jest.fn() +const mockSetPlcSwitchPosition = jest.fn() + +/** Captures the callback the hook subscribes with, so tests can drive it. */ +let pushed: ((payload: { port: string; plcState?: number; switchPosition?: number }) => void) | null = null +const mockUnsubscribe = jest.fn() +let onPlcStateImpl: unknown = (cb: (p: { port: string; plcState?: number; switchPosition?: number }) => void) => { + pushed = cb + return mockUnsubscribe +} + +jest.mock('../../../middleware/shared/providers', () => ({ + useDevice: () => ({ onPlcState: onPlcStateImpl }), +})) + +jest.mock('../../store', () => ({ + useOpenPLCStore: (selector: (s: unknown) => unknown) => + selector({ + deviceActions: { + setPlcRuntimeStatus: mockSetPlcRuntimeStatus, + setPlcSwitchPosition: mockSetPlcSwitchPosition, + }, + }), +})) + +import { useDevicePlcState } from '../use-device-plc-state' + +describe('useDevicePlcState', () => { + beforeEach(() => { + jest.clearAllMocks() + pushed = null + onPlcStateImpl = (cb: (p: { port: string; plcState?: number; switchPosition?: number }) => void) => { + pushed = cb + return mockUnsubscribe + } + }) + + it('maps a RUNNING push with the switch in RUN', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 1, switchPosition: 1 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('RUNNING') + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith('run') + }) + + it('maps STOPPED with the switch in STOP', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 0, switchPosition: 0 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('STOPPED') + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith('stop') + }) + + it('maps the ERROR state', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 2, switchPosition: 1 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('ERROR') + }) + + it('leaves the status untouched when the firmware reports no state', () => { + // Firmware predating the run/stop state machine omits the field. Inventing a + // status would make the button lie, so the hook writes nothing. + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x' }) + + expect(mockSetPlcRuntimeStatus).not.toHaveBeenCalled() + // ...and the switch reads as "unknown", which the start pre-check must treat + // as "no gating" rather than blocking. + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith(null) + }) + + it('is inert on a platform whose DevicePort has no held link', () => { + // The web platform has no serial link, so the optional method is absent. + onPlcStateImpl = undefined + expect(() => renderHook(() => useDevicePlcState())).not.toThrow() + expect(mockSetPlcRuntimeStatus).not.toHaveBeenCalled() + }) + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useDevicePlcState()) + unmount() + expect(mockUnsubscribe).toHaveBeenCalled() + }) +}) diff --git a/src/frontend/hooks/__tests__/use-runtime-polling.test.ts b/src/frontend/hooks/__tests__/use-runtime-polling.test.ts index 8f956442e..8c482650a 100644 --- a/src/frontend/hooks/__tests__/use-runtime-polling.test.ts +++ b/src/frontend/hooks/__tests__/use-runtime-polling.test.ts @@ -5,6 +5,7 @@ import { renderHook } from '@testing-library/react' // rule Vitest's `vi.hoisted` was originally written against. Spelled out // long-hand (no Vitest API) so the suite runs under plain Jest. const mockSetPlcRuntimeStatus = jest.fn() +const mockSetPlcSwitchPosition = jest.fn() const mockSetTimingStats = jest.fn() const mockSetEthercatStatus = jest.fn() const mockSetRuntimeJwtToken = jest.fn() @@ -28,6 +29,7 @@ const mockState: Record = { workspace: { plcLogs: '', plcLogsLastId: null }, deviceActions: { setPlcRuntimeStatus: mockSetPlcRuntimeStatus, + setPlcSwitchPosition: mockSetPlcSwitchPosition, setTimingStats: mockSetTimingStats, setEthercatStatus: mockSetEthercatStatus, setRuntimeJwtToken: mockSetRuntimeJwtToken, diff --git a/src/frontend/hooks/use-device-connect.ts b/src/frontend/hooks/use-device-connect.ts new file mode 100644 index 000000000..25c9cf53a --- /dev/null +++ b/src/frontend/hooks/use-device-connect.ts @@ -0,0 +1,127 @@ +/** + * useDeviceConnect (D72) — the persistent CONNECT for USB device screens. + * + * "Connect" opens the device channel and — unlike the earlier transient probe — + * the main process HOLDS the link open (a liveness poll keeps it honest, the + * port yields to upload/debug and reconnects afterwards). This hook drives that + * toggle and the follow-up UX from the initial classification: + * + * - no-response → channel wouldn't open (wrong port / busy) → error dialog. + * - no-firmware → opened, but nothing spoke the debug protocol → offer to + * Build & Upload (flash) the firmware, then reconnect. + * - connected-with-firmware → link held. + * + * Live link state (`deviceConnection.status`) is pushed from the main process. + */ +import type { BoardInfo } from '@root/middleware/shared/ports/types' +import { useDevice } from '@root/middleware/shared/providers/platform-context' +import { describeDebugEndpoint } from '@root/middleware/shared/utils/debug-endpoint' +import { useCallback } from 'react' + +import { resolveDeviceLinkWithUx } from '../services/device-link-resolution' +import { useOpenPLCStore } from '../store' +import { requestDeviceFlash } from '../utils/device-connect-events' + +export interface UseDeviceConnectResult { + /** Open + hold the link for the given board. Never throws. */ + connect: () => Promise + /** Close the held link. */ + disconnect: () => Promise + /** Live link status, mirrored from the main process. */ + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** Convenience flags derived from `status`. */ + isConnecting: boolean + isConnected: boolean +} + +export function useDeviceConnect(boardInfo: BoardInfo | undefined): UseDeviceConnectResult { + const device = useDevice() + const openModal = useOpenPLCStore((s) => s.modalActions.openModal) + const setDeviceConnectionStatus = useOpenPLCStore((s) => s.deviceActions.setDeviceConnectionStatus) + const status = useOpenPLCStore((s) => s.deviceConnection.status) + + const connect = useCallback(async (): Promise => { + const deviceBoard = useOpenPLCStore.getState().deviceDefinitions.configuration.deviceBoard + + // FIRST PASS: everything that needs nothing from the user — serial, then + // Modbus TCP on a static address. `deferPrompts` means a DHCP channel is set + // aside rather than interrupting with an address dialog, because with a cable + // attached the user should never be asked for one. + const silent = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { deferPrompts: true }) + if (!silent) return + if (silent.candidates.length === 0 && silent.awaitingInput.length === 0) return + + const tried: string[] = [] + setDeviceConnectionStatus('connecting', null) + + let result: { status: 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error'; error?: string } = { + status: 'no-response', + } + if (silent.candidates.length > 0) { + tried.push(...silent.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(silent.candidates.map((candidate) => candidate.config)) + } + + // SECOND PASS: nothing silent worked, so now it is worth asking. Resolving + // only the deferred channels surfaces the address dialog, and a cancel here + // ends the attempt rather than looping. + if (result.status !== 'connected-with-firmware' && silent.awaitingInput.length > 0) { + const prompted = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { + onlyChannels: silent.awaitingInput, + }) + if (prompted && prompted.candidates.length > 0) { + tried.push(...prompted.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(prompted.candidates.map((candidate) => candidate.config)) + } + } + + const endpoints = tried.join(' or ') || 'this device' + + if (result.status === 'no-response') { + openModal('debugger-message', { + type: 'error', + title: 'No Response', + message: `Could not reach the device on ${endpoints}. Check that it is powered and plugged in, and that the port or IP address is correct.`, + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } + + if (result.status === 'error') { + openModal('debugger-message', { + type: 'error', + title: 'Connection Error', + message: result.error ?? 'An unexpected error occurred while connecting to the device.', + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } + + if (result.status === 'no-firmware') { + openModal('debugger-message', { + type: 'question', + title: 'No Firmware Detected', + message: `No OpenPLC firmware responded on ${endpoints}. Build & Upload the program to flash this device, then Connect again.`, + buttons: ['Build & Upload', 'Cancel'], + onResponse: (buttonIndex: number) => { + if (buttonIndex === 0) requestDeviceFlash() + }, + }) + } + }, [boardInfo, device, openModal, setDeviceConnectionStatus]) + + const disconnect = useCallback(async (): Promise => { + await device.disconnect() + setDeviceConnectionStatus('disconnected', null) + }, [device, setDeviceConnectionStatus]) + + return { + connect, + disconnect, + status, + isConnecting: status === 'connecting', + isConnected: status === 'connected', + } +} diff --git a/src/frontend/hooks/use-device-connection-monitor.ts b/src/frontend/hooks/use-device-connection-monitor.ts new file mode 100644 index 000000000..4916a2c67 --- /dev/null +++ b/src/frontend/hooks/use-device-connection-monitor.ts @@ -0,0 +1,128 @@ +/** + * useDeviceConnectionMonitor — mirrors the held baremetal serial link into the + * store, and warns when it is lost for good. + * + * This is a WORKSPACE-level concern, not a device-screen one. The link outlives + * the screen that opened it: an upload, a debug session and the Start/Stop button + * all depend on `deviceConnection.status` being true. Subscribing inside the + * device screen left the store reading 'connected' after the cable was pulled + * whenever the user happened to be editing a POU, so every request timed out + * against a link the UI still advertised as up. + * + * The main process owns the state machine (liveness poll -> reopen attempts -> + * give up); this hook only reflects it. A cable pulled and plugged back in shows + * up as connected -> connecting -> connected with nothing to click. The warning + * fires only on `reason: 'lost'`, i.e. recovery gave up — an 'error' raised by + * something the user just clicked already has its own dialog, and warning twice + * for one click is worse than not warning at all. + * + * Mount once at the workspace level, next to `useDevicePlcState`. + */ +import { useEffect } from 'react' + +import { useDevice } from '../../middleware/shared/providers' +import { resolveRuntimeDebugChannel } from '../services/device-link-resolution' +import { useOpenPLCStore } from '../store' + +/** + * Keep the main process's session in step with a Runtime v3/v4 login. + * + * A runtime target is CONTROLLED over REST, which is connectionless — logging in + * is what establishes its session. This mirrors that: when the runtime connection + * comes up, tell the manager where control lives and how this target debugs; when + * it goes down, close the session. The debug channel itself is not opened here — + * the debugger asks for it when it needs it. + * + * The debug channel is DESCRIBED by resolving the board's spec (in the resolution + * service, which owns spec interpretation) — legitimate at session-establishment + * time. No command ever resolves anything. + */ +const useRuntimeSession = (): void => { + const device = useDevice() + const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) + const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) + + useEffect(() => { + if (!device.openRuntimeSession) return + + if (connectionStatus !== 'connected') { + void device.closeRuntimeSession?.() + return + } + + const store = useOpenPLCStore.getState() + const boardTarget = store.deviceDefinitions.configuration.deviceBoard + const boardInfo = store.deviceAvailableOptions.availableBoards.get(boardTarget) + const address = store.runtimeConnection.ipAddress + + // Every early return says why. Returning quietly is what let a runtime target + // end up with no session at all while the UI showed it connected, so that every + // command answered "not connected" on a target the user had just uploaded to. + if (!address) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: '[connection] runtime is connected but has no address recorded; no session opened', + }) + return + } + const debugChannel = resolveRuntimeDebugChannel(boardTarget, boardInfo) + if (!debugChannel) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: `[connection] no debug channel could be described for ${boardTarget}; debugging will not be available`, + }) + return + } + + void device.openRuntimeSession({ address, debug: debugChannel }).then((result) => { + if (!result.success) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `[connection] could not open the runtime session: ${result.error ?? 'unknown error'}`, + }) + } + }) + }, [device, connectionStatus, jwtToken]) +} + +export const useDeviceConnectionMonitor = (): void => { + useRuntimeSession() + const device = useDevice() + const addLog = useOpenPLCStore((state) => state.consoleActions.addLog) + const setDeviceConnectionStatus = useOpenPLCStore((state) => state.deviceActions.setDeviceConnectionStatus) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + + // Mirror the main process's connection trace into the console. The interesting + // decisions (which candidate was tried, what each poll concluded, which + // connection served a command) happen in main; without this the user watching + // the UI sees only "connecting..." and then a failure. + useEffect(() => { + if (!device.onLinkLog) return + return device.onLinkLog((message) => { + addLog({ id: crypto.randomUUID(), level: 'info', message: `[connection] ${message}` }) + }) + }, [device, addLog]) + + useEffect(() => { + return device.onConnectionStatus(({ status, descriptor, transport, debugTransport, reason }) => { + setDeviceConnectionStatus(status, descriptor ?? null, transport ?? null, debugTransport ?? null) + + if (status === 'error' && reason === 'lost') { + const endpoint = descriptor ?? 'the device' + // Name the endpoint AND what to check for that transport: "the cable" is + // useless advice for a link that was running over ethernet. + const advice = + transport === 'tcp' + ? 'Check that the device is powered and reachable on the network, then Connect again.' + : 'Check that the cable is plugged in and the port is not in use, then Connect again.' + openModal('runtime-connection-lost', { + label: endpoint, + body: `The connection to ${endpoint} was lost and could not be restored. ${advice}`, + }) + } + }) + }, [device, setDeviceConnectionStatus, openModal]) +} diff --git a/src/frontend/hooks/use-device-plc-state.ts b/src/frontend/hooks/use-device-plc-state.ts new file mode 100644 index 000000000..9ce1ffa88 --- /dev/null +++ b/src/frontend/hooks/use-device-plc-state.ts @@ -0,0 +1,61 @@ +/** + * useDevicePlcState — mirrors a baremetal target's run/stop state into the store. + * + * There is no timer here. The main process already polls the held device link to + * keep it honest, and that liveness read is the status frame (FC 0x46), which + * carries the run/stop state and the mode-switch position. This hook only + * subscribes to what that tick already pushes, so the Start/Stop button tracks + * the device — including a switch flipped by hand at the panel — without any + * extra traffic, a second timer, or a transient connection. + * + * Writes the SAME `runtimeConnection.plcStatus` the Runtime v4 poll writes, so + * every consumer (the button icon, its tooltip, the debugger's "PLC is stopped" + * prompt) works unchanged regardless of target type. + * + * Mount once at the workspace level, next to `useRuntimePolling`. + */ +import { useEffect } from 'react' + +import { PlcRuntimeState, PlcSwitchPosition } from '../../backend/shared/simulator/types' +import type { PlcStatus } from '../../middleware/shared/ports/types' +import { useDevice } from '../../middleware/shared/providers' +import { useOpenPLCStore } from '../store' + +/** Map the wire value to the store's PlcStatus union. */ +function toPlcStatus(state: number | undefined): PlcStatus | null { + switch (state) { + case PlcRuntimeState.RUNNING: + return 'RUNNING' + case PlcRuntimeState.STOPPED: + return 'STOPPED' + case PlcRuntimeState.ERROR: + return 'ERROR' + default: + // Firmware predating the run/stop state machine omits the field. Leave the + // status untouched rather than inventing one — the button then behaves as + // it did before, and the Start path reports `unsupported` if used. + return null + } +} + +export const useDevicePlcState = (): void => { + const device = useDevice() + const setPlcRuntimeStatus = useOpenPLCStore((state) => state.deviceActions.setPlcRuntimeStatus) + const setPlcSwitchPosition = useOpenPLCStore((state) => state.deviceActions.setPlcSwitchPosition) + + useEffect(() => { + // Optional on the port: the web platform has no held serial link. + if (!device.onPlcState) return + return device.onPlcState(({ plcState, switchPosition }) => { + const status = toPlcStatus(plcState) + if (status !== null) setPlcRuntimeStatus(status) + + // Absent on older firmware, which means "no switch gating" — null rather + // than a guessed 'run', so the pre-check can tell "no switch" from + // "switch says RUN". + setPlcSwitchPosition( + switchPosition === PlcSwitchPosition.STOP ? 'stop' : switchPosition === PlcSwitchPosition.RUN ? 'run' : null, + ) + }) + }, [device, setPlcRuntimeStatus, setPlcSwitchPosition]) +} diff --git a/src/frontend/hooks/use-runtime-polling.ts b/src/frontend/hooks/use-runtime-polling.ts index 294722135..c7986612a 100644 --- a/src/frontend/hooks/use-runtime-polling.ts +++ b/src/frontend/hooks/use-runtime-polling.ts @@ -22,6 +22,7 @@ export const useRuntimePolling = () => { const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const setPlcRuntimeStatus = useOpenPLCStore((state) => state.deviceActions.setPlcRuntimeStatus) + const setPlcSwitchPosition = useOpenPLCStore((state) => state.deviceActions.setPlcSwitchPosition) const setTimingStats = useOpenPLCStore((state) => state.deviceActions.setTimingStats) const setEthercatStatus = useOpenPLCStore((state) => state.deviceActions.setEthercatStatus) const openModal = useOpenPLCStore((state) => state.modalActions.openModal) @@ -36,6 +37,7 @@ export const useRuntimePolling = () => { deviceActions.setRuntimeJwtToken(null) deviceActions.setRuntimeConnectionStatus('disconnected') deviceActions.setPlcRuntimeStatus(null) + deviceActions.setPlcSwitchPosition(null) deviceActions.setTimingStats(null) deviceActions.setEthercatStatus(null) }, []) @@ -116,6 +118,9 @@ export const useRuntimePolling = () => { ? (rawStatus as PlcStatus) : 'UNKNOWN' setPlcRuntimeStatus(plcStatus) + // Runtime v4 reports the mode-switch position alongside the state; + // absent on older runtimes, which means "no gating". + setPlcSwitchPosition(statusResult.switchPosition ?? null) if (includeTimingStatsInPolling && statusResult.timingStats) { setTimingStats(statusResult.timingStats) @@ -169,7 +174,7 @@ export const useRuntimePolling = () => { } finally { isPollingRef.current = false } - }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setTimingStats, setEthercatStatus]) + }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setPlcSwitchPosition, setTimingStats, setEthercatStatus]) // Keep the store's connection token in lock-step with the platform's token // authority. When the authority transparently refreshes an expired token diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index f309f5fa1..99d70ed4b 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -12,8 +12,8 @@ import { useCallback, useRef } from 'react' -import type { DebugConnectionConfig, DebugTreeNode, FbInstanceInfo } from '../../middleware/shared/ports/types' -import { useDebugger, useSimulator } from '../../middleware/shared/providers' +import type { DebugTreeNode, FbInstanceInfo } from '../../middleware/shared/ports/types' +import { useDebugger } from '../../middleware/shared/providers' import { useOpenPLCStore } from '../store' import { parseDebugMap } from '../utils/debug-parser' import { @@ -32,10 +32,10 @@ export interface UseDebugSessionReturn { * connects via the debugger port, stores all artifacts in workspace, * and activates the debugger UI. * - * @param config — Connection target (simulator, TCP, RTU, WebSocket). - * If omitted, defaults to simulator. + * Takes nothing: the connection manager holds the session for every target by the + * time a debug session can start, so there is no medium for a caller to name. */ - connectAndStart: (config?: DebugConnectionConfig) => Promise<{ success: boolean; error?: string }> + connectAndStart: () => Promise<{ success: boolean; error?: string }> /** Disconnect from the debug target and clear all debug state. */ stopSession: () => Promise @@ -49,7 +49,6 @@ export interface UseDebugSessionReturn { export function useDebugSession(): UseDebugSessionReturn { const debuggerPort = useDebugger() - const simulator = useSimulator() const { project: { data: projectData, meta: projectMeta }, @@ -61,8 +60,7 @@ export function useDebugSession(): UseDebugSessionReturn { const debugTreesRef = useRef>({}) const connectAndStart = useCallback( - async (config?: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> => { - const debugConfig = config ?? ({ connectionType: 'simulator', connectionParams: {} } as DebugConnectionConfig) + async (): Promise<{ success: boolean; error?: string }> => { const { project, workspaceActions: wsActions, consoleActions: logActions } = useOpenPLCStore.getState() const boardTarget = deviceDefinitions.configuration.deviceBoard const projectPath = project.meta.path @@ -154,7 +152,7 @@ export function useDebugSession(): UseDebugSessionReturn { } // Connect debugger via port - const connectResult = await debuggerPort.connect(debugConfig) + const connectResult = await debuggerPort.connect() if (!connectResult.success) { const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) @@ -174,9 +172,10 @@ export function useDebugSession(): UseDebugSessionReturn { }) // Set target IP for non-simulator connections - if (debugConfig.connectionType !== 'simulator' && debugConfig.connectionParams.ipAddress) { - wsActions.setDebuggerTargetIp(debugConfig.connectionParams.ipAddress) - } + // The target's address, for the debugger's own display. Comes from the + // session the manager holds, not from a config the caller chose. + const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port + if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) // Record the active transport so useDebugPolling picks the right // poll cadence + batch size. Set on EVERY start path (runtime @@ -186,7 +185,9 @@ export function useDebugSession(): UseDebugSessionReturn { // the default 200ms instead of its intended 50ms). Must be set // before `setDebuggerVisible(true)`, which is what triggers the // polling effect. - wsActions.setDebugConnectionType(debugConfig.connectionType) + // The medium is the manager's choice, mirrored in the store; read it rather + // than assuming one. `debugTransport` because this sizes the debug poll. + wsActions.setDebugConnectionType(useOpenPLCStore.getState().deviceConnection.debugTransport ?? 'simulator') wsActions.setDebuggerVisible(true) logActions.addLog({ @@ -205,19 +206,20 @@ export function useDebugSession(): UseDebugSessionReturn { [debuggerPort, deviceDefinitions, projectData, projectMeta], ) + /** + * End the debug session — and ONLY the debug session. + * + * It used to stop the simulator too, which had the ownership backwards: a debug + * session is a consumer of a connection, not the owner of the thing on the other + * end. Stopping the simulator is the Stop button's job (`handleSimulatorControl`), + * and closing that session is the connection manager's. + */ const stopSession = useCallback(async () => { - // If simulator is running, stop it - if (simulator.isRunning()) { - await simulator.stop() - } - - // Disconnect debugger await debuggerPort.disconnect() - // Clear all debug state workspaceActions.clearDebugState() debugTreesRef.current = {} - }, [simulator, debuggerPort, workspaceActions]) + }, [debuggerPort, workspaceActions]) const forceVariable = useCallback( async (index: number, force: boolean, value?: string, type?: string, enumValues?: string[]): Promise => { diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 8bad1b9f9..fc73bacab 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -52,6 +52,8 @@ import { useDebugNonBoolValuesMap, useIsDebuggerVisible, } from '../hooks/use-debug-value' +import { useDeviceConnectionMonitor } from '../hooks/use-device-connection-monitor' +import { useDevicePlcState } from '../hooks/use-device-plc-state' import { useRuntimePolling } from '../hooks/use-runtime-polling' import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' import { useOpenPLCStore } from '../store' @@ -151,6 +153,10 @@ const WorkspaceScreen = () => { // Start global runtime polling for status and logs useRuntimePolling() + // Mirrors a baremetal target's run/stop state from the held device link's + // existing liveness tick (no timer of its own). + useDevicePlcState() + useDeviceConnectionMonitor() // Build debug variables from POUs with debug=true const allDebugVariables = useMemo(() => { diff --git a/src/frontend/services/__tests__/device-link-resolution.test.ts b/src/frontend/services/__tests__/device-link-resolution.test.ts new file mode 100644 index 000000000..566a67c8a --- /dev/null +++ b/src/frontend/services/__tests__/device-link-resolution.test.ts @@ -0,0 +1,111 @@ +/** + * Describing a Runtime v3/v4 target's debug channel — through the SAME resolver + * Connect uses, with the target's declared transports deciding what is eligible. + * + * The regression these pin: eligibility used to be a hardcoded serial-then-TCP list + * inside the resolver, so a `websocket` channel was never a candidate. No runtime + * session was ever opened, and every command then answered "not connected" on a + * target the user had connected to and uploaded a program to. The fix is not a + * second code path for runtimes — it is asking the target which media it speaks. + */ +const mockAddLog = jest.fn() + +const mockState: Record = { + deviceDefinitions: { configuration: { deviceBoard: 'OpenPLC Runtime v4', runtimeIpAddress: '192.168.0.42' } }, + runtimeConnection: { connectionStatus: 'connected', jwtToken: 'jwt-token' }, + consoleActions: { addLog: mockAddLog }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) + +import type { DebugSpec } from '../../../backend/shared/hardware/debug-spec' +import type { BoardInfo } from '../../../middleware/shared/ports/types' +import { resolveRuntimeDebugChannel } from '../device-link-resolution' + +/** A board carries BOTH halves: the spec says how a channel is built, the + * capability matrix says which channels the target can actually speak. */ +const boardWith = (spec: DebugSpec, transports: string[]): BoardInfo => + ({ debug: spec, capabilities: { debuggerTransports: transports } }) as unknown as BoardInfo + +/** The shape a Runtime v4 board declares — an SLM-RP4's, verbatim. */ +const v4Spec: DebugSpec = { + preconditions: ['runtimeConnected', 'jwtToken'], + channels: [ + { + label: 'WebSocket', + channel: 'websocket', + enabledWhen: true, + params: { + ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not configured.' }, + jwtToken: { $ref: 'runtimeConnection.jwtToken', required: 'JWT token missing. Reconnect to the runtime.' }, + }, + }, + ], +} + +/** Runtime v3: same shape, debugged over Modbus TCP instead. */ +const v3Spec: DebugSpec = { + preconditions: ['runtimeConnected'], + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not set.' } }, + }, + ], +} + +beforeEach(() => { + jest.clearAllMocks() + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: 'jwt-token' } +}) + +describe('resolveRuntimeDebugChannel', () => { + it('describes a v4 target as its WebSocket channel', () => { + const config = resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket'])) + + expect(config).not.toBeNull() + expect(config?.connectionType).toBe('websocket') + expect(config?.connectionParams.ipAddress).toBe('192.168.0.42') + expect(config?.connectionParams.jwtToken).toBe('jwt-token') + }) + + it('describes a v3 target as its Modbus TCP channel', () => { + const config = resolveRuntimeDebugChannel('OpenPLC Runtime v3', boardWith(v3Spec, ['modbus-tcp'])) + + expect(config?.connectionType).toBe('tcp') + expect(config?.connectionParams.ipAddress).toBe('192.168.0.42') + }) + + it('returns null and SAYS SO when a board declares no debug spec', () => { + // Failing quietly is what hid the bug above until it reached hardware. + expect(resolveRuntimeDebugChannel('Some Board', undefined)).toBeNull() + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('no debug spec') }), + ) + }) + + it('returns null and says why when the spec cannot be satisfied', () => { + // v4 requires a JWT; without one the resolver refuses, and the user should be + // able to see that rather than meet "not connected" later. + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: null } + + expect(resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket']))).toBeNull() + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('could NOT describe a debug channel') }), + ) + }) + + it('traces the channel it settled on', () => { + resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket'])) + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('debug channel is websocket') }), + ) + }) +}) diff --git a/src/frontend/services/device-link-resolution.ts b/src/frontend/services/device-link-resolution.ts new file mode 100644 index 000000000..c882b7d17 --- /dev/null +++ b/src/frontend/services/device-link-resolution.ts @@ -0,0 +1,319 @@ +/** + * Turning a board's declarative `debug` spec into something connectable, with the + * dialogs that sometimes takes. + * + * Two flows need this and they used to share nothing: + * + * - Connect (and the reconnect after an upload) needs the ORDERED CANDIDATES for + * the device link — Modbus TCP when the project enables it, serial otherwise. + * - The debugger needs ONE channel for a session (or, for a runtime target, its + * WebSocket). + * + * What they have in common is the interactive part: a spec can ask for input (the + * DHCP address) or offer a choice, which means resolve → ask → resolve again. That + * loop lives here once. When only Connect had it, Connect could not ask for a DHCP + * address at all; when only the debugger had it, Connect silently mis-resolved. + * + * Deliberately not a hook: it is called from click handlers, keeps no React state, + * and the modal helpers below reach the store directly — so the same functions + * serve the activity bar and the device screen without either owning the other. + */ +import { + type DebugResolverContext, + type DeviceLinkCandidateConfig, + resolveDeviceLinkCandidates, +} from '../../backend/shared/hardware/debug-spec' +import type { BoardInfo, DebugConnectionConfig } from '../../middleware/shared/ports/types' +import { describeDebugEndpoint } from '../../middleware/shared/utils/debug-endpoint' +import { resolveTargetCapabilities } from '../../middleware/shared/utils/target-capabilities' +import { useOpenPLCStore } from '../store' + +/** + * Answers the user has already given, keyed by board then by the spec's + * `cacheKey`. Scoped per board so two devices sharing a cache key (`lastDhcpIp`) + * do not inherit each other's address. Module-level: it should outlive any one + * screen, since the same answer serves Connect and the debugger. + */ +const promptCache: Record> = {} + +/** Discard cached answers for a board — used when an entered value stops working. */ +export function forgetPromptAnswers(boardTarget: string): void { + delete promptCache[boardTarget] +} + +/** + * The editor's device dialogs, in one place. Exported because the flows that + * surround resolution — the debug gate, the switch warning, upload prompts — need + * to speak in the same voice, and a second copy of this two-line promise wrapper + * is how two callers end up with subtly different buttons. + */ +export const showDeviceDialog = ( + type: 'info' | 'warning' | 'error' | 'question', + title: string, + message: string, + buttons: string[], + /** Which button is the primary, and which one Escape / click-away chooses. */ + options?: { primaryButtonIndex?: number; dismissButtonIndex?: number }, +): Promise => + new Promise((resolve) => { + useOpenPLCStore.getState().modalActions.openModal('debugger-message', { + type, + title, + message, + buttons, + ...options, + onResponse: (buttonIndex: number) => resolve(buttonIndex), + }) + }) + +export const showDeviceInput = (title: string, message: string, defaultValue: string): Promise => + new Promise((resolve) => { + useOpenPLCStore.getState().modalActions.openModal('debugger-ip-input', { + title, + message, + defaultValue, + onSubmit: (value: string) => resolve(value), + onCancel: () => resolve(null), + }) + }) + +/** + * Build resolver context from current store state on every call, so the user's + * freshest screen edits count without saving first. `boardTarget` selects the + * prompt-cache bucket. + * + * `runtimeReadyForDebug` is passed in rather than read here: it comes from the + * runtime port, which only a component can reach, and it is meaningless for the + * baremetal flows. + */ +export function buildDeviceResolverContext( + boardTarget: string, + options: { runtimeReadyForDebug?: boolean } = {}, +): DebugResolverContext { + const store = useOpenPLCStore.getState() + const cfg = store.deviceDefinitions.configuration + const runtimeConnection = store.runtimeConnection + // `vendorScreenData` is already keyed by section id (`modbus_rtu`), which is + // the resolver's `screens` shape 1:1. + const screens = (cfg.vendorScreenData ?? {}) as Record> + + return { + state: { + configuration: { + deviceBoard: cfg.deviceBoard, + ...(cfg.communicationPort ? { communicationPort: cfg.communicationPort } : {}), + ...(cfg.runtimeIpAddress ? { runtimeIpAddress: cfg.runtimeIpAddress } : {}), + }, + screens, + runtimeConnection: { + ...(runtimeConnection.connectionStatus ? { connectionStatus: runtimeConnection.connectionStatus } : {}), + ...(runtimeConnection.jwtToken ? { jwtToken: runtimeConnection.jwtToken } : {}), + }, + promptCache: promptCache[boardTarget] ?? {}, + }, + capabilities: { + runtimeConnected: options.runtimeReadyForDebug === true && runtimeConnection.connectionStatus === 'connected', + jwtToken: Boolean(runtimeConnection.jwtToken), + }, + } +} + +/** Outcomes both resolvers can return besides their own success shape. */ +type InteractiveOutcome = + | { kind: 'pick'; channels: Array<{ index: number; label: string }>; title: string; body: string } + | { + kind: 'prompt' + fields: Array<{ field: string; title: string; message: string; cacheKey?: string; defaultValue?: string }> + channelIndex: number + } + | { kind: 'error'; title: string; body: string } + | { kind: 'unsupported' } + +/** What the caller should do next after a non-success outcome. */ +type NextStep = + /** Resolve again; `channelIndex` is the user's pick, if they made one. */ + | { retry: true; channelIndex?: number } + /** Stop: the user cancelled, or there is nothing to connect to. */ + | { retry: false } + +/** + * Surface whatever the resolver asked for, and say whether to resolve again. + * + * A cancelled prompt or picker stops the flow — the user said no, so no dialog is + * repeated and nothing is guessed on their behalf. + */ +async function handleInteractiveOutcome(outcome: InteractiveOutcome, boardTarget: string): Promise { + if (outcome.kind === 'error') { + await showDeviceDialog('warning', outcome.title, outcome.body, ['OK']) + return { retry: false } + } + if (outcome.kind === 'unsupported') return { retry: false } + + if (outcome.kind === 'pick') { + const choice = await showDeviceDialog( + 'question', + outcome.title, + outcome.body, + outcome.channels.map((channel) => channel.label), + ) + if (choice < 0 || choice >= outcome.channels.length) return { retry: false } + return { retry: true, channelIndex: outcome.channels[choice].index } + } + + // prompt: collect every field, caching answers the spec asked to remember. + const bucket = (promptCache[boardTarget] ??= {}) + for (const field of outcome.fields) { + const previous = field.cacheKey ? bucket[field.cacheKey] : undefined + const answer = await showDeviceInput(field.title, field.message, previous ?? field.defaultValue ?? '') + if (answer === null) return { retry: false } + const trimmed = answer.trim() + if (!trimmed) return { retry: false } + if (field.cacheKey) bucket[field.cacheKey] = trimmed + } + return { retry: true, channelIndex: outcome.channelIndex } +} + +/** + * Trace resolution into the console. Resolution happens HERE, in the renderer, + * from the project's screen data — so when a transport is not attempted at all, + * this is the only place that can say why. + */ +function trace(message: string): void { + useOpenPLCStore.getState().consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `[connection] ${message}`, + }) +} + +/** Guard against a malformed spec bouncing between prompts forever. */ +const MAX_RESOLVE_ROUNDS = 8 + +/** What resolution found, and what it deliberately left unasked. */ +export interface ResolvedDeviceLink { + /** Ways to reach the device that need nothing from the user, in try-order. */ + candidates: DeviceLinkCandidateConfig[] + /** + * Channel indexes that could also be tried, but only after asking the user + * something. Resolve again with `onlyChannels: awaitingInput` to ask. + */ + awaitingInput: number[] +} + +/** + * Resolve the ways to reach a baremetal device: serial first, then Modbus TCP. + * + * By default this asks the user NOTHING — a channel needing input is reported in + * `awaitingInput` instead. That is what lets Connect try the cable before asking + * for a DHCP address, so a user with a cable attached is never interrupted by a + * dialog about an address they do not need to know. + * + * Pass `onlyChannels` to resolve just those channels, asking whatever they need; + * that is the second pass, run only once everything silent has failed. + * + * Returns null if the user cancelled, or the board declares nothing connectable + * (the dialog explaining why has already been shown). + */ +export async function resolveDeviceLinkWithUx( + boardTarget: string, + boardInfo: BoardInfo | undefined, + options: { runtimeReadyForDebug?: boolean; onlyChannels?: number[]; deferPrompts?: boolean } = {}, +): Promise { + const spec = boardInfo?.debug + const transports = resolveTargetCapabilities(boardInfo).debuggerTransports + if (!spec) { + await showDeviceDialog( + 'warning', + 'Cannot Connect', + 'This board has not declared a debug spec, so the editor has no way to reach it. The VPP package must provide a `debug` block.', + ['OK'], + ) + return null + } + + const resolverOptions = { + transports, + ...(options.onlyChannels ? { onlyChannels: options.onlyChannels } : {}), + ...(options.deferPrompts ? { deferPrompts: true } : {}), + } + + for (let round = 0; round < MAX_RESOLVE_ROUNDS; round += 1) { + const context = buildDeviceResolverContext(boardTarget, options) + const outcome = resolveDeviceLinkCandidates(spec, context, resolverOptions) + if (outcome.kind === 'candidates') { + trace( + `resolved ${outcome.candidates.length} candidate(s) for ${boardTarget}: ${ + outcome.candidates + .map((candidate) => `${candidate.config.connectionType} ${describeDebugEndpoint(candidate.config)}`) + .join(', ') || 'none' + }${outcome.awaitingInput.length ? ` (+${outcome.awaitingInput.length} needing input, not asked yet)` : ''}`, + ) + return { candidates: outcome.candidates, awaitingInput: outcome.awaitingInput } + } + // Say what the spec concluded and what it was reading, so a transport that is + // never attempted can be traced to the screen value that ruled it out. + trace( + `resolution returned "${outcome.kind}"${outcome.kind === 'error' ? `: ${outcome.body}` : ''} — modbus_tcp=${JSON.stringify( + context.state.screens.modbus_tcp ?? null, + )} modbus_rtu=${JSON.stringify(context.state.screens.modbus_rtu ?? null)} port=${String( + context.state.configuration.communicationPort ?? 'none', + )}`, + ) + + const next = await handleInteractiveOutcome(outcome, boardTarget) + if (!next.retry) return null + } + return null +} + +/** + * The channel a Runtime v3/v4 debugs over: the WebSocket for v4, Modbus TCP for v3. + * Used when a runtime login establishes a session, so the manager knows how to open + * that channel later. + * + * Uses the SINGLE-channel resolver, not the candidate one. A runtime declares + * exactly one debug channel and there is nothing to choose between or order — while + * `resolveDeviceLinkCandidates` exists to order a baremetal board's serial and + * Modbus TCP options, and collects only those two kinds. Pointing it at a + * `websocket` channel therefore found nothing eligible, returned an error, and left + * every runtime target without a session: "nothing is connected" for both the + * debugger and run/stop, on a target the user had plainly connected to. + * + * Never prompts (v3/v4 specs declare no prompts) and traces its own failure, because + * a session that cannot be described must not fail silently — that silence is what + * hid this until it reached hardware. + */ +export function resolveRuntimeDebugChannel( + boardTarget: string, + boardInfo: BoardInfo | undefined, +): DebugConnectionConfig | null { + const spec = boardInfo?.debug + if (!spec) { + trace(`${boardTarget}: no debug spec, so no debug channel can be described`) + return null + } + + // The SAME resolver Connect uses. A runtime declares exactly one debug transport + // in its capability matrix (`['websocket']` for v4, `['modbus-tcp']` for v3), so + // the ordered candidate list has one entry — no separate code path, and no + // hardcoded assumption here about what a runtime debugs over. + const outcome = resolveDeviceLinkCandidates( + spec, + buildDeviceResolverContext(boardTarget, { runtimeReadyForDebug: true }), + { transports: resolveTargetCapabilities(boardInfo).debuggerTransports, deferPrompts: true }, + ) + if (outcome.kind === 'candidates' && outcome.candidates.length > 0) { + const [channel] = outcome.candidates + trace(`${boardTarget}: debug channel is ${channel.config.connectionType} (${channel.channelLabel})`) + return channel.config + } + + // Never silently: a session that cannot be described leaves every later command + // answering "not connected" on a target the user believes they are connected to. + trace( + `${boardTarget}: could NOT describe a debug channel — resolver returned "${outcome.kind}"${ + outcome.kind === 'error' ? `: ${outcome.body}` : '' + }`, + ) + return null +} diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index d92bc5ee4..604401a1e 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -155,6 +155,43 @@ describe('createDeviceSlice', () => { expect(store.getState().deviceActions).toBeDefined() expect(typeof store.getState().deviceActions.setAvailableOptions).toBe('function') }) + + it('has a disconnected serial connection', () => { + const store = makeStore() + expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + }) + }) + + // ----------------------------------------------------------------------- + // serial connection (D72 persistent link) + // ----------------------------------------------------------------------- + describe('serial connection', () => { + it('setDeviceConnectionStatus updates status and port', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') + expect(store.getState().deviceConnection).toEqual({ status: 'connecting', port: 'COM5', transport: null, debugTransport: null }) + }) + + it('setDeviceConnectionStatus leaves the port unchanged when omitted', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') + store.getState().deviceActions.setDeviceConnectionStatus('connected') + expect(store.getState().deviceConnection).toEqual({ status: 'connected', port: 'COM5', transport: null, debugTransport: null }) + }) + + it('setDeviceConnectionStatus can explicitly clear the port with null', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.setDeviceConnectionStatus('error', null) + expect(store.getState().deviceConnection).toEqual({ status: 'error', port: null, transport: null, debugTransport: null }) + }) + + it('clearDeviceConnection resets to disconnected/null', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.clearDeviceConnection() + expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + }) }) // ----------------------------------------------------------------------- @@ -173,7 +210,7 @@ describe('createDeviceSlice', () => { it('sets available communication ports', () => { const store = makeStore() - const ports: CommunicationPort[] = [{ name: 'COM3', address: '/dev/ttyUSB0' }] + const ports: CommunicationPort[] = [{ address: '/dev/ttyUSB0', manufacturer: 'FTDI' }] store.getState().deviceActions.setAvailableOptions({ availableCommunicationPorts: ports }) expect(store.getState().deviceAvailableOptions.availableCommunicationPorts).toEqual(ports) }) @@ -185,14 +222,14 @@ describe('createDeviceSlice', () => { ]) store.getState().deviceActions.setAvailableOptions({ availableBoards: boards }) store.getState().deviceActions.setAvailableOptions({ - availableCommunicationPorts: [{ name: 'COM1', address: '/dev/tty1' }], + availableCommunicationPorts: [{ address: '/dev/tty1' }], }) expect(store.getState().deviceAvailableOptions.availableBoards.size).toBe(1) }) it('does not overwrite ports when only boards given', () => { const store = makeStore() - const ports: CommunicationPort[] = [{ name: 'COM1', address: '/dev/tty1' }] + const ports: CommunicationPort[] = [{ address: '/dev/tty1' }] store.getState().deviceActions.setAvailableOptions({ availableCommunicationPorts: ports }) store.getState().deviceActions.setAvailableOptions({ availableBoards: new Map(), @@ -304,6 +341,13 @@ describe('createDeviceSlice', () => { expect(rc.ethercatStatus).toBeNull() expect(rc.includeEthercatStatsInPolling).toBe(false) }) + + it('resets the serial connection', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.clearDeviceDefinitions() + expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + }) }) // ----------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index 41de632aa..ee6a2dba3 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -9,6 +9,8 @@ import type { PinUpdateResponse, RuntimeConnection, SelectedDevice, + DeviceConnection, + DeviceConnectionStatus, StoredCredentials, } from '../slices/device' @@ -95,6 +97,7 @@ describe('Device slice types', () => { jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -137,6 +140,7 @@ describe('Device slice types', () => { jwtToken: 'token', connectionStatus: 'connected', plcStatus: 'RUNNING', + switchPosition: 'run', ipAddress: '192.168.1.1', runtimeVersion: 'v4.1.9', selectedDevice: { @@ -178,6 +182,7 @@ describe('Device slice types', () => { jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -187,11 +192,29 @@ describe('Device slice types', () => { ethercatStatus: null, includeEthercatStatsInPolling: false, }, + deviceConnection: { status: 'disconnected', port: null, transport: null, debugTransport: null }, } expect(state.deviceAvailableOptions).toBeDefined() expect(state.deviceDefinitions).toBeDefined() expect(state.deviceUpdated).toBeDefined() expect(state.runtimeConnection).toBeDefined() + expect(state.deviceConnection).toBeDefined() + }) + }) + + // ----------------------------------------------------------------------- + // DeviceConnection + // ----------------------------------------------------------------------- + describe('DeviceConnection', () => { + it('accepts every status', () => { + const statuses: DeviceConnectionStatus[] = ['disconnected', 'connecting', 'connected', 'error'] + const conns: DeviceConnection[] = statuses.map((status) => ({ + status, + port: status === 'connected' ? 'COM5' : null, + transport: status === 'connected' ? 'rtu' : null, + debugTransport: status === 'connected' ? 'rtu' : null, + })) + expect(conns).toHaveLength(4) }) }) diff --git a/src/frontend/store/slices/device/index.ts b/src/frontend/store/slices/device/index.ts index fb1725ef7..89bfbc0aa 100644 --- a/src/frontend/store/slices/device/index.ts +++ b/src/frontend/store/slices/device/index.ts @@ -3,6 +3,8 @@ export type { ConnectionStatus, DeviceActions, DeviceAvailableOptions, + DeviceConnection, + DeviceConnectionStatus, DevicePinMapping, DeviceSlice, DeviceState, diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 3740ab094..f5f77384e 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -50,6 +50,7 @@ const createDeviceSlice: StateCreator = (s jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -59,6 +60,12 @@ const createDeviceSlice: StateCreator = (s ethercatStatus: null, includeEthercatStatsInPolling: false, }, + deviceConnection: { + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }, deviceActions: { setAvailableOptions: ({ availableBoards, availableCommunicationPorts }): void => { @@ -109,7 +116,7 @@ const createDeviceSlice: StateCreator = (s }, clearDeviceDefinitions: (): void => { setState( - produce(({ deviceDefinitions, runtimeConnection }: DeviceSlice) => { + produce(({ deviceDefinitions, runtimeConnection, deviceConnection }: DeviceSlice) => { deviceDefinitions.configuration = defaultDeviceConfiguration deviceDefinitions.pinMapping = { pinsByBoard: {}, @@ -126,6 +133,12 @@ const createDeviceSlice: StateCreator = (s runtimeConnection.includeTimingStatsInPolling = false runtimeConnection.ethercatStatus = null runtimeConnection.includeEthercatStatsInPolling = false + // The held device link is meaningless once the project is closed — + // reset it so a stale connection can't leak into the next one. + deviceConnection.status = 'disconnected' + deviceConnection.port = null + deviceConnection.transport = null + deviceConnection.debugTransport = null }), ) }, @@ -461,6 +474,13 @@ const createDeviceSlice: StateCreator = (s }), ) }, + setPlcSwitchPosition: (position): void => { + setState( + produce(({ runtimeConnection }: DeviceSlice) => { + runtimeConnection.switchPosition = position + }), + ) + }, setSelectedDevice: (device): void => { setState( produce(({ runtimeConnection }: DeviceSlice) => { @@ -527,6 +547,26 @@ const createDeviceSlice: StateCreator = (s }), ) }, + setDeviceConnectionStatus: (status, port, transport, debugTransport): void => { + setState( + produce(({ deviceConnection }: DeviceSlice) => { + deviceConnection.status = status + if (port !== undefined) deviceConnection.port = port + if (transport !== undefined) deviceConnection.transport = transport + if (debugTransport !== undefined) deviceConnection.debugTransport = debugTransport + }), + ) + }, + clearDeviceConnection: (): void => { + setState( + produce(({ deviceConnection }: DeviceSlice) => { + deviceConnection.status = 'disconnected' + deviceConnection.port = null + deviceConnection.transport = null + deviceConnection.debugTransport = null + }), + ) + }, setVendorScreenData: (persistenceKey, data): void => { setState( produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index 5a035b325..daaf7251f 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -63,6 +63,13 @@ export type RuntimeConnection = { jwtToken: string | null connectionStatus: ConnectionStatus plcStatus: PlcStatus | null + /** Run/stop mode-switch position of the connected target, or null when + * unknown. Lives next to `plcStatus` so the Start/Stop button, its tooltip + * and the start pre-check all read one value, whatever the target type: + * Runtime v4 fills it from `/api/status`, baremetal from the device status + * poll. `'run'` on any device without a physical switch, so a null-safe + * caller treats absence as "no gating". */ + switchPosition: 'run' | 'stop' | null ipAddress: string | null /** Version string reported by the connected runtime (from * get-users-info / the X-OpenPLC-Runtime-Version header), or null @@ -76,6 +83,36 @@ export type RuntimeConnection = { includeEthercatStatsInPolling: boolean } +// --------------------------------------------------------------------------- +// Persistent serial connection (D72) — baremetal "stay connected" +// --------------------------------------------------------------------------- + +export type DeviceConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' + +/** + * Live state of the connection the main process holds to a baremetal target, + * mirroring the connection manager — which remains the source of truth. Purely + * whether the connection is up, and over what. + */ +export type DeviceConnection = { + status: DeviceConnectionStatus + /** Endpoint the connection is on (or was last attempted on): a serial path or an IP. */ + port: string | null + /** + * Medium the CONTROL channel uses. Read-only mirror — nothing in the renderer + * picks a transport. Null for a REST-controlled runtime session, which holds no + * connection. + */ + transport: 'rtu' | 'tcp' | 'simulator' | null + /** + * Medium the DEBUG channel uses. The debug poll sizes its batches to this, because + * the frame budget differs enormously by wire (WebSocket 500 variables per round + * trip, Modbus TCP 60, RTU 19). Mirrored from the manager rather than inferred: a + * v4 session left this unset and silently polled with TCP-sized batches. + */ + debugTransport: 'rtu' | 'tcp' | 'simulator' | 'websocket' | null +} + // --------------------------------------------------------------------------- // Device state // --------------------------------------------------------------------------- @@ -91,6 +128,7 @@ export type DeviceState = { updated: boolean } runtimeConnection: RuntimeConnection + deviceConnection: DeviceConnection } // --------------------------------------------------------------------------- @@ -150,6 +188,8 @@ export type DeviceActions = { setRuntimeConnectionStatus: (status: ConnectionStatus) => void setRuntimeVersion: (version: string | null) => void setPlcRuntimeStatus: (status: PlcStatus | null) => void + /** Set the mode-switch position (null clears it, e.g. on disconnect). */ + setPlcSwitchPosition: (position: 'run' | 'stop' | null) => void setSelectedDevice: (device: SelectedDevice | null) => void setStoredCredentials: (credentials: StoredCredentials | null) => void setTimingStats: (stats: TimingStats | null) => void @@ -158,6 +198,15 @@ export type DeviceActions = { setIncludeEthercatStatsInPolling: (include: boolean) => void setTemporaryDhcpIp: (ipAddress?: string) => void clearRuntimeConnection: () => void + /** Set the persistent serial link state (optionally the port it's on). */ + setDeviceConnectionStatus: ( + status: DeviceConnectionStatus, + port?: string | null, + transport?: DeviceConnection['transport'], + debugTransport?: DeviceConnection['debugTransport'], + ) => void + /** Reset the serial link to disconnected/null. */ + clearDeviceConnection: () => void setVendorScreenData: (persistenceKey: string, data: unknown) => void /** Restore `vendorScreenData[k]` for every k in `ownedKeys`: from * `snapshot[k]` when present, else by deleting the key. Used by diff --git a/src/frontend/utils/__tests__/serial-port-label.test.ts b/src/frontend/utils/__tests__/serial-port-label.test.ts new file mode 100644 index 000000000..132b867af --- /dev/null +++ b/src/frontend/utils/__tests__/serial-port-label.test.ts @@ -0,0 +1,98 @@ +import { serialPortDisplay } from '../serial-port-label' + +/** + * This helper is the ONE place a port's label is decided, so the platform + * expectations are asserted here rather than inferred from the producer. + */ +describe('serialPortDisplay', () => { + describe('the path always leads', () => { + it('labels a Windows port by its COM number', () => { + expect(serialPortDisplay({ address: 'COM5', boardName: 'Arduino Uno' })).toEqual({ + label: 'COM5 (Arduino Uno)', + title: 'COM5 (Arduino Uno)', + }) + }) + + it('labels a macOS port by its /dev/cu path', () => { + expect(serialPortDisplay({ address: '/dev/cu.usbmodem11101', boardName: 'Arduino MKR' })).toEqual({ + label: '/dev/cu.usbmodem11101 (Arduino MKR)', + title: '/dev/cu.usbmodem11101 (Arduino MKR)', + }) + }) + + it('labels a Linux port by its /dev/tty path', () => { + expect(serialPortDisplay({ address: '/dev/ttyACM0', boardName: 'Arduino Mega' })).toEqual({ + label: '/dev/ttyACM0 (Arduino Mega)', + title: '/dev/ttyACM0 (Arduino Mega)', + }) + }) + + it('never replaces the path with the descriptor', () => { + // The original bug: a NodeMCU reading "wch.cn" instead of "COM5". + const { label } = serialPortDisplay({ address: 'COM5', manufacturer: 'wch.cn' }) + expect(label.startsWith('COM5')).toBe(true) + expect(label).not.toBe('wch.cn') + }) + }) + + describe('descriptor precedence', () => { + it('prefers the arduino-cli board name over the manufacturer', () => { + // Both scans found something; the board name is the specific one. + expect( + serialPortDisplay({ address: 'COM1', boardName: 'Opta', manufacturer: 'Arduino' }), + ).toEqual({ label: 'COM1 (Opta)', title: 'COM1 (Opta)' }) + }) + + it('falls back to the manufacturer when arduino-cli identified no board', () => { + expect(serialPortDisplay({ address: 'COM6', manufacturer: 'com0com - serial port emulator' })).toEqual({ + label: 'COM6 (com0com - serial port emulator)', + title: 'COM6 (com0com - serial port emulator)', + }) + }) + + it('shows the bare path when neither scan knew a descriptor', () => { + expect(serialPortDisplay({ address: '/dev/ttyUSB0' })).toEqual({ + label: '/dev/ttyUSB0', + title: undefined, + }) + }) + + it('treats a blank descriptor as absent', () => { + expect(serialPortDisplay({ address: 'COM3', boardName: ' ', manufacturer: '' })).toEqual({ + label: 'COM3', + title: undefined, + }) + }) + + it('falls through a blank board name to the manufacturer', () => { + expect(serialPortDisplay({ address: 'COM3', boardName: ' ', manufacturer: 'FTDI' })).toEqual({ + label: 'COM3 (FTDI)', + title: 'COM3 (FTDI)', + }) + }) + }) + + describe('degenerate inputs', () => { + it('falls back to the descriptor when the address is empty', () => { + expect(serialPortDisplay({ address: '', boardName: 'Arduino Uno' })).toEqual({ + label: 'Arduino Uno', + title: undefined, + }) + }) + + it('trims surrounding whitespace', () => { + expect(serialPortDisplay({ address: ' COM5 ', manufacturer: ' wch.cn ' })).toEqual({ + label: 'COM5 (wch.cn)', + title: 'COM5 (wch.cn)', + }) + }) + + it('cannot double-wrap, because it never receives a composed string', () => { + // The regression this structure prevents: when the producer pre-composed + // `name`, the renderer had to guess whether it already contained the path. + const { label } = serialPortDisplay({ address: 'COM5', boardName: 'Arduino Uno' }) + expect(label).toBe('COM5 (Arduino Uno)') + expect(label).not.toContain('COM5 (COM5') + }) + }) +}) diff --git a/src/frontend/utils/device-connect-events.ts b/src/frontend/utils/device-connect-events.ts new file mode 100644 index 000000000..d1f06e2d8 --- /dev/null +++ b/src/frontend/utils/device-connect-events.ts @@ -0,0 +1,23 @@ +/** + * Tiny decoupled bridge for the CONNECT flow (D72). The device screen's + * "no firmware" dialog lives in `board.tsx`, but Build & Upload lives in the + * workspace activity bar (`default.tsx`). Rather than hoist build state up or + * thread refs across the component tree, the dialog fires a DOM CustomEvent the + * activity bar listens for. Same window, synchronous dispatch — no payload. + */ +const FLASH_REQUEST_EVENT = 'openplc:device-flash-request' + +/** Ask the workspace activity bar to run Build & Upload (flash the firmware). */ +export function requestDeviceFlash(): void { + window.dispatchEvent(new CustomEvent(FLASH_REQUEST_EVENT)) +} + +/** + * Subscribe to flash requests. Returns an unsubscribe function suitable for a + * React effect cleanup. + */ +export function onDeviceFlashRequest(handler: () => void): () => void { + const listener = (): void => handler() + window.addEventListener(FLASH_REQUEST_EVENT, listener) + return () => window.removeEventListener(FLASH_REQUEST_EVENT, listener) +} diff --git a/src/frontend/utils/serial-port-label.ts b/src/frontend/utils/serial-port-label.ts new file mode 100644 index 000000000..3b697d4e6 --- /dev/null +++ b/src/frontend/utils/serial-port-label.ts @@ -0,0 +1,41 @@ +import type { CommunicationPort } from '../../middleware/shared/ports/types' + +/** + * How a serial port reads in the communication-port picker: + * `/dev/cu.usbmodem11101 (Arduino MKR)`, `COM5 (Arduino Uno)`, `COM5 (wch.cn)`. + * + * Two rules, and they used to pull against each other: + * + * 1. The path always leads. It is what the user recognizes and what we + * actually open — `COM5` on Windows, `/dev/ttyUSB0` on Linux, + * `/dev/cu.usbmodem*` on macOS — so it is never replaced by a descriptor. + * (The bug that motivated this: a NodeMCU reading "wch.cn" instead of + * "COM5", because the label took a manufacturer string over the path.) + * 2. The descriptor survives, in parentheses. It is what distinguishes two + * identical-looking `/dev/cu.usbmodem*` nodes, and dropping it was the + * regression that followed. + * + * Both hold because this composes rather than choosing, and it is the ONE place + * that decides — `CommunicationPort` carries facts (`address`, `boardName`, + * `manufacturer`), never a pre-composed string. That is what makes every + * platform behave identically: there is no second labelling path to drift. + * + * Descriptor precedence: arduino-cli's identified board name first (it is the + * specific, useful one), falling back to the OS vendor/manufacturer string, and + * to nothing at all when neither scan knew anything. + */ +export function serialPortDisplay(port: CommunicationPort): { label: string; title?: string } { + const address = port.address?.trim() ?? '' + const descriptor = port.boardName?.trim() || port.manufacturer?.trim() || '' + + // No path to lead with (shouldn't happen — the enumerator keys on it) — the + // descriptor is all there is. + if (!address) return { label: descriptor, title: undefined } + + if (!descriptor) return { label: address, title: undefined } + + const label = `${address} (${descriptor})` + // Offer the full string on hover as well: a composed label is the one most + // likely to be truncated by the dropdown's width. + return { label, title: label } +} diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index e0f8cc87b..4b31d9765 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,8 +1,15 @@ import { ESIService } from '@root/backend/editor/ethercat' import { createDesktopCatalogTransport } from '@root/backend/editor/library-manager/desktop-catalog-transport' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import type { + DebugStatusResult, + DeviceDebugChannel, + DeviceModbusTransport, + PlcControlResult, +} from '@root/backend/shared/debug/types' import { parseESIDeviceFull } from '@root/backend/shared/ethercat/esi-parser-main' import { listPublicLibraries } from '@root/backend/shared/library/public-catalog-client' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { RuntimeLogEntry } from '@root/middleware/shared/ports' @@ -22,6 +29,7 @@ import type { ListPublicLibrariesResponse, } from '@root/middleware/shared/ports/public-catalog-types' import type { RuntimeUser, RuntimeUserRole, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' +import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' import { createRuntimeTokenManager } from '@root/middleware/shared/runtime-auth/runtime-token-manager' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' @@ -38,9 +46,23 @@ import { join, resolve, sep } from 'path' import { platform } from 'process' import { MainIpcModule, MainIpcModuleConstructor } from '../../../backend/editor/contracts/types/modules/ipc/main' +import { + type DeviceDebugCandidate, + type DeviceLinkCandidate, + type DeviceLinkStatus, + DeviceSessionManager, +} from '../../../backend/editor/hardware/device-session-manager' +import { + buildDeviceModbusTransport, + modbusTransportKind, +} from '../../../backend/editor/hardware/device-transport-factory' +import { + classifyDeviceLink, + type DeviceProbeOutcome, + PATIENT_BOARD_ID_PROBE, + QUICK_BOARD_ID_PROBE, +} from '../../../backend/editor/hardware/device-probe' import { LibraryManagerModule } from '../../../backend/editor/library-manager' -import { ModbusTcpClient } from '../../../backend/editor/modbus/modbus-client' -import { ModbusRtuClient } from '../../../backend/editor/modbus/modbus-rtu-client' import { PackageManagerModule } from '../../../backend/editor/package-manager' import { logger } from '../../../backend/editor/services' import { @@ -52,6 +74,18 @@ import { import { WebSocketDebugTransport } from '../../../backend/shared/debug/websocket-debug-transport' import { SimulatorModule } from '../../../backend/shared/simulator/simulator-module' import { VirtualSerialPort } from '../../../backend/shared/simulator/virtual-serial-port' +import { describeDebugEndpoint } from '../../../middleware/shared/utils/debug-endpoint' + +/** Why a channel could not be handed out. */ +interface ChannelUnavailable { + error: string + needsReconnect: true +} + +/** Program-identity comparison, case-insensitively — targets report either case. */ +function matchesMd5(targetMd5: string, expectedMd5: string): boolean { + return targetMd5.toLowerCase() === expectedMd5.toLowerCase() +} class MainProcessBridge implements MainIpcModule { ipcMain @@ -63,15 +97,29 @@ class MainProcessBridge implements MainIpcModule { compilerModule hardwareModule private registeredHandleChannels: string[] = [] - private debuggerModbusClient: ModbusTcpClient | ModbusRtuClient | null = null - private debuggerWebSocketClient: WebSocketDebugTransport | null = null - private debuggerTargetIp: string | null = null - private debuggerReconnecting: boolean = false + // --------------------------------------------------------------------------- + // Talking to a baremetal device + // + // ONE session, owned by `deviceSession`, whatever media it runs over: the + // debugger, run/stop and the status poll all borrow that one client. + // Nothing else here opens a Modbus client — see `device-link-manager.ts` for + // why (in short: three owners meant a run/stop command could open a second + // socket the board would not answer). + // + // The runtime-v4 WebSocket is the one transport that is NOT a device link: it + // is a different protocol to a different kind of target, so it keeps its own + // client and its own session identity. + // --------------------------------------------------------------------------- + private readonly deviceSession = new DeviceSessionManager({ + verify: (client, candidate, context) => this.verifyDeviceCandidate(client, candidate, context), + probe: (client) => this.probeDeviceLink(client), + serialPortPresent: (port) => this.hardwareModule.isSerialPortPresent(port), + emit: (status) => this.emitDeviceLinkStatus(status), + log: (message) => this.traceDeviceLink(message), + }) + /** Classification of the candidate the held link came from. */ + private deviceLinkProbe: DeviceProbeOutcome | null = null private debuggerConnectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator' | null = null - private debuggerRtuPort: string | null = null - private debuggerRtuBaudRate: number | null = null - private debuggerRtuSlaveId: number | null = null - private debuggerJwtToken: string | null = null // Address of the runtime this session is authenticated against. Captured at // login so the token authority can re-authenticate against the same device. private runtimeIp: string | null = null @@ -636,10 +684,14 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiRequest<{ status: string timing_stats?: TimingStatsResponse + // Run/stop mode-switch position. Absent on runtimes older than the + // run/stop interface — treat undefined as "no gating". + switchPosition?: 'run' | 'stop' }>(ipAddress, endpoint, (data: string) => { const response = JSON.parse(data) as { status: string timing_stats?: TimingStatsResponse + switchPosition?: 'run' | 'stop' } return response }) @@ -664,6 +716,7 @@ class MainProcessBridge implements MainIpcModule { success: true, status: result.data.status, timingStats, + ...(result.data.switchPosition ? { switchPosition: result.data.switchPosition } : {}), } } else { return { success: false, error: !result.success ? result.error : 'Unknown error' } @@ -673,24 +726,7 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeStartPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { - try { - // Parse the body so the renderer can drive a retry-on-BUSY - // loop around `COMMAND:BUSY` replies (the runtime answers BUSY - // while it's still unloading the previous program after an - // upload). See `backend/shared/library/start-plc-after-build.ts`. - const result = await this.makeRuntimeApiRequest<{ status?: string }>( - ipAddress, - '/api/start-plc', - (data: string) => JSON.parse(data) as { status?: string }, - ) - if (!result.success) return { success: false, error: result.error } - const status = (result.data?.status ?? '').trim() - return { success: true, status } - } catch (error) { - return { success: false, error: getErrorMessage(error) } - } - } + handleRuntimeStartPlc = (_event: IpcMainInvokeEvent, ipAddress: string) => this.restStartPlc(ipAddress) handleRuntimeStopPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { @@ -1017,11 +1053,17 @@ class MainProcessBridge implements MainIpcModule { // ===================== DEBUGGER ===================== this.registerHandle('debugger:verify-md5', this.handleDebuggerVerifyMd5) + this.registerHandle('debugger:plc-control', this.handleDebuggerPlcControl) this.registerHandle('debugger:read-program-st-md5', this.handleReadProgramStMd5) this.registerHandle('debugger:get-variables-list', this.handleDebuggerGetVariablesList) this.registerHandle('debugger:set-variable', this.handleDebuggerSetVariable) this.registerHandle('debugger:connect', this.handleDebuggerConnect) this.registerHandle('debugger:disconnect', this.handleDebuggerDisconnect) + this.registerHandle('device:connect', this.handleDeviceConnect) + this.registerHandle('device:disconnect', this.handleDeviceDisconnect) + this.registerHandle('device:release-serial-port', this.handleDeviceReleaseSerialPort) + this.registerHandle('session:open-runtime', this.handleOpenRuntimeSession) + this.registerHandle('session:close-runtime', this.handleCloseRuntimeSession) // ===================== RUNTIME API ===================== this.registerHandle('runtime:get-users-info', this.handleRuntimeGetUsersInfo) @@ -1629,16 +1671,16 @@ class MainProcessBridge implements MainIpcModule { } } + /** + * Confirm the target is running the program that was just built. + * + * Modbus targets (serial, TCP, simulator) read this over the ONE held device + * link, so the check runs on the same connection every later command uses — no + * second client, and nothing to reconnect afterwards. Runtime v4 reads it over + * its own WebSocket, which is a different protocol to a different target. + */ handleDebuggerVerifyMd5 = async ( _event: IpcMainInvokeEvent, - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, expectedMd5: string, ): Promise<{ success: boolean @@ -1647,111 +1689,134 @@ class MainProcessBridge implements MainIpcModule { targetEndian?: 'le' | 'be' error?: string }> => { - let client: ModbusTcpClient | ModbusRtuClient | null = null - let wsClient: WebSocketDebugTransport | null = null try { - if (connectionType === 'simulator') { - const virtualPort = new VirtualSerialPort(this.simulatorModule) - client = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - await client.connect() - const { md5: targetMd5, targetEndian } = await client.getMd5Hash() - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() - - // Keep the client for subsequent debug operations - this.debuggerModbusClient = client - this.debuggerConnectionType = 'simulator' - - return { success: true, match, targetMd5, targetEndian } - } else if (connectionType === 'websocket') { - if (!connectionParams.ipAddress || !connectionParams.jwtToken) { - return { success: false, error: 'IP address and JWT token are required for WebSocket connection' } - } - if (!this.debuggerWebSocketClient) { - wsClient = new WebSocketDebugTransport({ - host: connectionParams.ipAddress, - port: 8443, - token: connectionParams.jwtToken, - rejectUnauthorized: false, - }) - await wsClient.connect() - } else { - wsClient = this.debuggerWebSocketClient - } - - const { md5: targetMd5, targetEndian } = await wsClient.getMd5Hash() + const channel = await this.requireDebug('verify md5') + if ('error' in channel) return { success: false, error: channel.error } - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + const probe = await channel.client.getMd5Hash() + return { success: true, match: matchesMd5(probe.md5, expectedMd5), ...probe } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error during MD5 verification', + } + } + } - if (!this.debuggerWebSocketClient) { - this.debuggerWebSocketClient = wsClient - this.debuggerTargetIp = connectionParams.ipAddress - this.debuggerJwtToken = connectionParams.jwtToken - this.debuggerConnectionType = 'websocket' - } + /** + * FC 0x4b run/stop for a baremetal target. + * + * Command only — the state is READ from the status poll (FC 0x46), which already + * reports it, so there is no second round trip here. + * + * Goes over the ONE held device link, whatever transport that link runs over. + * The transport the DEBUGGER is using is not consulted, and no client is opened: + * this is a command to the device, and the connection to it already exists. + * + * That is precisely what was broken. The old code only recognised an RTU client + * as reusable, so with a live Modbus TCP session a Stop fell through to opening a + * transient second socket — which an Arduino Modbus TCP server, serving one + * client at a time, never answered. The user saw "Failed to stop PLC: Request + * timeout" while a working connection sat idle. + */ + handleDebuggerPlcControl = async ( + _event: IpcMainInvokeEvent, + action: 'run' | 'stop', + ): Promise => { + this.traceDeviceLink(`run/stop: ${action} requested`) - return { success: true, match, targetMd5, targetEndian } - } else if (connectionType === 'tcp') { - if (!connectionParams.ipAddress) { - return { success: false, error: 'IP address is required for TCP connection' } - } - client = new ModbusTcpClient({ - host: connectionParams.ipAddress, - port: 502, - timeout: 5000, - }) - } else { - if (!connectionParams.port || !connectionParams.baudRate || connectionParams.slaveId === undefined) { - return { success: false, error: 'Port, baud rate, and slave ID are required for RTU connection' } - } + // Routed by the session's CONTROL channel, which is the whole point: the caller + // said "stop the PLC" and does not know or care whether that means a Modbus + // function code on a cable or an HTTP POST to a runtime. + const restAddress = this.deviceSession.getRestAddress() + if (restAddress !== null) return this.restSetPlcState(restAddress, action) - // Reuse existing RTU client if already connected to the same port - if ( - this.debuggerModbusClient && - this.debuggerConnectionType === 'rtu' && - this.debuggerRtuPort === connectionParams.port - ) { - const { md5: targetMd5, targetEndian } = await this.debuggerModbusClient.getMd5Hash() - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() - return { success: true, match, targetMd5, targetEndian } - } + const link = this.requireControl('run/stop') + if ('error' in link) return { success: false, error: link.error } - client = new ModbusRtuClient({ - port: connectionParams.port, - baudRate: connectionParams.baudRate, - slaveId: connectionParams.slaveId, - timeout: 5000, - }) + const target = action === 'run' ? PlcRuntimeState.RUNNING : PlcRuntimeState.STOPPED + try { + return await link.client.setPlcState(target) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error during PLC control request', } + } + } - await client.connect() - const { md5: targetMd5, targetEndian } = await client.getMd5Hash() + /** + * Run/stop over a REST control channel, reported in the same shape the Modbus + * path returns — so the caller handles one result type, not two. + * + * `ERROR_SWITCH_STOP` in the runtime's reply is its way of saying the hardware + * mode switch refused a start, which is exactly what `refusedBySwitch` means on + * the Modbus side (FC 0x4b status 0x86). + */ + private async restSetPlcState(address: string, action: 'run' | 'stop'): Promise { + const result = + action === 'run' ? await this.restStartPlc(address) : await this.makeRuntimeApiRequest(address, '/api/stop-plc') + if (!result.success) return { success: false, error: result.error } - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + const status = 'status' in result ? (result.status ?? '') : '' + if (status.includes('ERROR_SWITCH_STOP')) return { success: false, refusedBySwitch: true } - if (connectionType === 'tcp') { - client.disconnect() - } else { - this.debuggerModbusClient = client - this.debuggerConnectionType = 'rtu' - this.debuggerRtuPort = connectionParams.port! - this.debuggerRtuBaudRate = connectionParams.baudRate! - this.debuggerRtuSlaveId = connectionParams.slaveId! - } + // The runtime settles into the new state on its next scan; report the state the + // command asked for so the button can reflect it without a second round trip. + return { success: true, state: action === 'run' ? PlcRuntimeState.RUNNING : PlcRuntimeState.STOPPED } + } - return { success: true, match, targetMd5, targetEndian } + /** The `/api/start-plc` call, shared by the session router and the IPC handler. */ + private async restStartPlc(address: string): Promise<{ success: boolean; status?: string; error?: string }> { + try { + // The body is parsed because the runtime answers `COMMAND:BUSY` while it is + // still unloading a previous program after an upload, and callers drive a + // retry loop on that. See `backend/shared/library/start-plc-after-build.ts`. + const result = await this.makeRuntimeApiRequest<{ status?: string }>( + address, + '/api/start-plc', + (data: string) => JSON.parse(data) as { status?: string }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, status: (result.data?.status ?? '').trim() } } catch (error) { - client?.disconnect() - wsClient?.disconnect() - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error during MD5 verification', + return { success: false, error: getErrorMessage(error) } + } + } + + handleDebuggerGetVariablesList = async ( + _event: IpcMainInvokeEvent, + variableIndexes: number[], + ): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: number[] + error?: string + needsReconnect?: boolean + }> => { + // A null connection type means the debugger was intentionally disconnected. + // Fail silently so the renderer's poll loop ignores it. + if (this.debuggerConnectionType === null) { + return { success: false, error: 'Debugger not connected' } + } + + // Every target reads over its session's DEBUG channel — Modbus for a device or + // a v3 runtime, the WebSocket for v4. There is nothing to reconnect here: if a + // connection dropped, the manager is already reopening it (or has reported it + // lost), and `needsReconnect` tells the renderer to stop the session rather + // than race it for the medium. + const link = await this.requireDebug('read variables') + if ('error' in link) return { success: false, error: link.error, needsReconnect: true } + + try { + const result = await link.client.getVariablesList(variableIndexes) + if (result.success && result.data) { + return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: getErrorMessage(error), needsReconnect: true } } } @@ -1787,289 +1852,419 @@ class MainProcessBridge implements MainIpcModule { } } - handleDebuggerGetVariablesList = async ( - _event: IpcMainInvokeEvent, - variableIndexes: number[], - ): Promise<{ - success: boolean - tick?: number - lastIndex?: number - data?: number[] - error?: string - needsReconnect?: boolean - }> => { - // If connection type is null, the debugger was intentionally disconnected. - // Return a silent failure so the renderer polling ignores it. - if (this.debuggerConnectionType === null) { - return { success: false, error: 'Debugger not connected' } + + /** + * Read the run/stop state over an already-open client and push it to the + * renderer. Throttled, because its two callers tick at very different rates: + * the device liveness poll (2.5s) and the debugger's variable poll (fast). + * + * Both callers use the ONE held connection, so there is no handoff to survive: + * a debug session shares the link rather than replacing it, and the Start/Stop + * button keeps tracking the device while debugging. + */ + private plcStatePushedAt = 0 + + private async pushPlcState( + client: { getStatus?: () => Promise }, + port: string, + minIntervalMs = 2000, + ): Promise { + if (!client.getStatus) return + const now = Date.now() + if (now - this.plcStatePushedAt < minIntervalMs) return + this.plcStatePushedAt = now + + const r = await client.getStatus() + if (!r.success) return + this.mainWindow?.webContents?.send('device:plc-state', { + port, + plcState: r.plcState, + switchPosition: r.switchPosition, + }) + } + + + + /** + * Start a debug session against a target. + * + * For a Modbus target there is nothing to open: the session runs over the ONE + * held device link, which Connect established and which the status poll keeps + * honest. That is what makes serial debugging work at all (the OS will not lock + * a port twice), and it is equally right for Modbus TCP (an Arduino TCP server + * serves one client). The simulator is the exception only because it is + * in-process, so it can bring its own link up on demand. + * + * Runtime v4 keeps its own WebSocket: different protocol, different target. + */ + handleDebuggerConnect = async (_event: IpcMainInvokeEvent): Promise<{ success: boolean; error?: string }> => { + try { + // For a shared session this opens nothing — it is the connection Connect + // established, already proven. For a runtime target it opens that target's + // own debug channel, which is why the debugger asks for it here rather than + // at login: the channel exists only while a session needs it. + const channel = await this.requireDebug('debug session') + if ('error' in channel) return { success: false, error: channel.error } + + // Session identity comes from the SESSION, not from what the caller guessed: + // a connected target names no transport at all. + this.debuggerConnectionType = this.deviceSession.getLink()?.transport ?? 'tcp' + return { success: true } + } catch (error) { + this.debuggerConnectionType = null + return { success: false, error: getErrorMessage(error) } } + } - if (this.debuggerConnectionType === 'websocket') { - if (!this.debuggerWebSocketClient) { - if (this.debuggerReconnecting) { - return { success: false, error: 'Reconnection in progress', needsReconnect: true } - } + /** + * Stop a debug session: let go of the debug channel, nothing more. + * + * The SESSION is deliberately untouched — it belongs to Connect (or to the + * runtime login), not to the debugger. Closing it here would drop the user's + * connection, and the status poll driving the Start/Stop button with it, just + * because they stopped debugging. Releasing closes a channel of its own once + * nothing holds it, and never closes a channel shared with control. + */ + handleDebuggerDisconnect = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + this.deviceSession.releaseDebugChannel('debug session') + this.debuggerConnectionType = null + return Promise.resolve({ success: true }) + } - this.debuggerReconnecting = true - try { - if (!this.debuggerTargetIp || !this.debuggerJwtToken) { - this.debuggerReconnecting = false - return { success: false, error: 'No target IP or JWT token stored', needsReconnect: true } - } - this.debuggerWebSocketClient = new WebSocketDebugTransport({ - host: this.debuggerTargetIp, - port: 8443, - token: this.debuggerJwtToken, - rejectUnauthorized: false, - }) - await this.debuggerWebSocketClient.connect() - this.debuggerReconnecting = false - } catch (error) { - this.debuggerWebSocketClient = null - this.debuggerReconnecting = false - return { success: false, error: `Failed to reconnect: ${getErrorMessage(error)}`, needsReconnect: true } - } - } + // =================================================================== + // The device link — "stay connected", over serial or Modbus TCP + // =================================================================== - try { - const result = await this.debuggerWebSocketClient.getVariablesList(variableIndexes) + /** Push a link state change to the renderer. */ + private emitDeviceLinkStatus(status: DeviceLinkStatus): void { + this.traceDeviceLink( + `status -> ${status.status}${status.descriptor ? ` (${status.transport ?? '?'} ${status.descriptor})` : ''}${ + status.reason ? ` [${status.reason}]` : '' + }`, + ) + this.mainWindow?.webContents?.send('device:connection-status', status) + } - if (result.success && result.data) { - return { - success: true, - tick: result.tick, - lastIndex: result.lastIndex, - data: Array.from(result.data), - } - } + /** + * Diagnostic trace for the device connection, to BOTH sinks on purpose: the + * main-process log file keeps it after the fact, and the renderer console puts + * it where a user can read and copy it while reproducing something. Connection + * problems span two processes and a piece of hardware; without this the only + * evidence is "it hangs". + */ + private traceDeviceLink(message: string): void { + logger.info(`[link] ${message}`) + this.mainWindow?.webContents?.send('device:link-log', message) + } - return { success: false, error: result.error } - } catch (error) { - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } - return { success: false, error: getErrorMessage(error), needsReconnect: true } + /** + * Turn a resolved channel config into something the link manager can try. + * The only transport-specific step left in the flow; a config that names a + * transport this build cannot speak is dropped rather than half-built. + */ + private toDeviceLinkCandidates(configs: DebugConnectionConfig[]): DeviceLinkCandidate[] { + const candidates: DeviceLinkCandidate[] = [] + for (const config of configs) { + const kind = modbusTransportKind(config.connectionType) + if (kind === null) continue + const params = { + connectionType: config.connectionType, + port: config.connectionParams.port, + baudRate: config.connectionParams.baudRate, + slaveId: config.connectionParams.slaveId, + host: config.connectionParams.ipAddress, } + // Only the simulator needs an in-process serial port; building one for a real + // transport would allocate a virtual port nobody reads. + const options = kind === 'simulator' ? { virtualSerialPort: new VirtualSerialPort(this.simulatorModule) } : {} + // Probe the params now so a malformed config fails resolution rather than + // becoming a candidate that always throws on `create()`. + if ('error' in buildDeviceModbusTransport(params, options)) continue + candidates.push({ + transport: kind, + descriptor: describeDebugEndpoint(config), + create: () => { + const built = buildDeviceModbusTransport(params, options) + if ('error' in built) throw new Error(built.error) + return built.client + }, + }) } + return candidates + } - if (!this.debuggerModbusClient) { - if (this.debuggerReconnecting) { - return { success: false, error: 'Reconnection in progress', needsReconnect: true } - } + /** Consume the classification the last verified candidate produced. */ + private takeDeviceLinkProbe(): DeviceProbeOutcome | null { + const probe = this.deviceLinkProbe + this.deviceLinkProbe = null + return probe + } - this.debuggerReconnecting = true - try { - if (this.debuggerConnectionType === 'simulator') { - const virtualPort = new VirtualSerialPort(this.simulatorModule) - this.debuggerModbusClient = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - } else if (this.debuggerConnectionType === 'tcp') { - if (!this.debuggerTargetIp) { - this.debuggerReconnecting = false - return { success: false, error: 'No target IP address stored', needsReconnect: true } - } - this.debuggerModbusClient = new ModbusTcpClient({ - host: this.debuggerTargetIp, - port: 502, - timeout: 5000, - }) - } else if (this.debuggerConnectionType === 'rtu') { - if (!this.debuggerRtuPort || !this.debuggerRtuBaudRate || this.debuggerRtuSlaveId === null) { - this.debuggerReconnecting = false - return { success: false, error: 'No RTU connection parameters stored', needsReconnect: true } - } - this.debuggerModbusClient = new ModbusRtuClient({ - port: this.debuggerRtuPort, - baudRate: this.debuggerRtuBaudRate, - slaveId: this.debuggerRtuSlaveId, - timeout: 5000, - }) - } else { - this.debuggerReconnecting = false - return { success: false, error: 'No connection type stored', needsReconnect: true } - } + /** + * Establish a session with a Runtime v3/v4: control over REST, debug over the + * channel its board declares (v3: Modbus TCP on the runtime's address; v4: the + * debug WebSocket). Called once the renderer has logged in. + * + * The debug channel is only DESCRIBED here, not opened — see `acquireDebugChannel`. + */ + handleOpenRuntimeSession = ( + _event: IpcMainInvokeEvent, + params: { address: string; debug: DebugConnectionConfig }, + ): Promise<{ success: boolean; error?: string }> => { + const candidate = this.toDebugCandidate(params.debug) + if (!candidate) { + return Promise.resolve({ + success: false, + error: `This target declares a debug channel this build cannot open: ${params.debug.connectionType}`, + }) + } + this.deviceSession.openRestSession({ address: params.address, debugChannel: candidate }) + this.debuggerConnectionType = params.debug.connectionType + return Promise.resolve({ success: true }) + } - await this.debuggerModbusClient.connect() - this.debuggerReconnecting = false - } catch (error) { - this.debuggerModbusClient = null - this.debuggerReconnecting = false - return { success: false, error: `Failed to reconnect: ${getErrorMessage(error)}`, needsReconnect: true } - } + /** Close a REST-controlled session (the user logged out / disconnected). */ + handleCloseRuntimeSession = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + if (this.deviceSession.getRestAddress() !== null) { + this.deviceSession.close() + this.debuggerConnectionType = null } + return Promise.resolve({ success: true }) + } - try { - const result = await this.debuggerModbusClient.getVariablesList(variableIndexes) + /** + * Turn a resolved channel config into an openable DEBUG channel. The one place + * that knows a WebSocket is a debug channel too. + */ + private toDebugCandidate(config: DebugConnectionConfig): DeviceDebugCandidate | null { + if (config.connectionType === 'websocket') { + const host = config.connectionParams.ipAddress + const token = config.connectionParams.jwtToken + if (!host || !token) return null + return { + transport: 'websocket', + descriptor: `websocket ${host}`, + create: () => new WebSocketDebugTransport({ host, port: 8443, token, rejectUnauthorized: false }), + } + } + const [candidate] = this.toDeviceLinkCandidates([config]) + if (!candidate) return null + return { + transport: candidate.transport, + descriptor: `${candidate.transport} ${candidate.descriptor}`, + create: candidate.create, + } + } - if (result.success && result.data) { - return { - success: true, - tick: result.tick, - lastIndex: result.lastIndex, - data: Array.from(result.data), + /** + * Is this freshly opened candidate a device we can work with? Runs the + * connect-time classification (`classifyDeviceLink`) and keeps its verdict for + * the renderer. + * + * Only `connected-with-firmware` keeps a candidate. A port that opens but has + * no firmware, or an IP that answers something else, therefore falls through to + * the next candidate instead of becoming a link that cannot serve a single + * command. + */ + private async verifyDeviceCandidate( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ): Promise { + // The simulator is in-process: there is no hardware to identify, so the + // board-id read is not the right question to ask of it. + // + // Retried because the session is opened the instant the emulator starts, and + // the sketch inside it still has to reach the point where it services Modbus. + // Failing here would stop an emulator that was merely still booting. + if (candidate.transport === 'simulator') { + for (let attempt = 0; attempt < MainProcessBridge.SIMULATOR_PROBE_ATTEMPTS; attempt += 1) { + try { + if (await this.probeDeviceLink(client)) return true + } catch { + // Not up yet — fall through to the wait below. } + await new Promise((resolve) => setTimeout(resolve, MainProcessBridge.SIMULATOR_PROBE_INTERVAL_MS)) } + return false + } + + // Be patient only with the LAST candidate. The id read is retried because a + // board that was just flashed may still be booting — worth ~32s when this is + // the only way in, but not while alternatives are waiting: a Modbus TCP + // address that no longer answers should not delay the cable that would have + // worked. (Measured on a real board: 32.5s to rule out one endpoint.) + const boardIdProbe = context.isLastCandidate ? PATIENT_BOARD_ID_PROBE : QUICK_BOARD_ID_PROBE + this.traceDeviceLink( + ` ${candidate.descriptor}: verifying with up to ${boardIdProbe.attempts} id read(s)` + + `${context.isLastCandidate ? ' (last candidate, being patient)' : ''}`, + ) + const result = await classifyDeviceLink(client, { boardIdProbe }) + this.deviceLinkProbe = result + this.traceDeviceLink( + ` ${candidate.descriptor}: classified as "${result.status}"${result.error ? ` (${result.error})` : ''}`, + ) + if (result.status !== 'connected-with-firmware') return false + // The status frame doubles as the run/stop state source; push it straight + // away so the Start/Stop button is right before the first poll lands. + await this.pushPlcState(client, candidate.descriptor, 0) + return true + } - return { success: false, error: result.error } - } catch (error) { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } - return { success: false, error: getErrorMessage(error), needsReconnect: true } + /** + * Per-tick liveness read, and the ONE place baremetal run/stop state is polled. + * + * Prefers the status read (FC 0x46) over the board id (0x48): both prove the + * firmware is answering, but the status frame also carries the run/stop state + * and the mode-switch position — so a switch flipped by hand at the panel shows + * up within one interval, with no second timer and no extra traffic. + */ + private async probeDeviceLink(client: DeviceModbusTransport): Promise { + const descriptor = this.deviceSession.getLink()?.descriptor ?? '' + if (client.getStatus) { + const status = await client.getStatus() + if (!status.success) return false + this.plcStatePushedAt = 0 + await this.pushPlcState(client, descriptor, 0) + return true } + return (await client.getBoardId()).success } - handleDebuggerConnect = async ( + /** + * Release the link if it holds `port` — the handoff before an upload takes the + * same serial port. A Modbus TCP link is left alone: flashing over USB does not + * disturb it, so debugging and run/stop survive an upload. + * + * Returns whether anything was released, so the caller knows to reconnect. + */ + handleDeviceReleaseSerialPort = async ( _event: IpcMainInvokeEvent, - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, - ): Promise<{ success: boolean; error?: string }> => { - try { - if (connectionType === 'simulator') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + port: string | null | undefined, + ): Promise<{ released: boolean }> => { + return { released: this.deviceSession.releaseSerialPort(port) } + } - const virtualPort = new VirtualSerialPort(this.simulatorModule) - this.debuggerModbusClient = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - await this.debuggerModbusClient.connect() - - // MD5 fetch warms the connection and exercises the - // runtime's endianness-sentinel path. Endianness detection - // itself is handled at the editor's verify-MD5 step (see - // handleDebuggerVerifyMd5) where the result feeds the swap - // layer; here we just need the connection live. - await this.debuggerModbusClient.getMd5Hash() - } else if (connectionType === 'websocket') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + /** The CONTROL channel's client (run/stop, status). */ + private deviceClient(): DeviceModbusTransport | null { + return this.deviceSession.getClient() + } - if (!connectionParams.ipAddress || !connectionParams.jwtToken) { - return { success: false, error: 'IP address and JWT token are required for WebSocket connection' } - } + /** + * The channel for this operation family, or a reason there isn't one. Every + * device command funnels through here, so "not connected" and "reconnecting" + * read the same everywhere instead of each handler inventing its own message — + * or, worse, opening its own connection. + * + * `debug` operations take the debug channel, which for a shared session IS the + * control channel and for a runtime target is one of its own. + */ + private requireControl(what: string): { client: DeviceModbusTransport } | ChannelUnavailable { + const client = this.deviceClient() + if (!client) return this.explainMissingChannel(what) + this.traceChannelUse(what, 'control') + return { client } + } - if (!this.debuggerWebSocketClient || this.debuggerConnectionType !== 'websocket') { - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } + /** + * The DEBUG channel, opening it if this session's debug medium is one of its own. + * Every debug caller passes a distinct `what`, which doubles as the holder name — + * so two callers can hold it at once without either closing it on the other. + */ + private async requireDebug(what: string): Promise<{ client: DeviceDebugChannel } | ChannelUnavailable> { + const acquired = await this.deviceSession.acquireDebugChannel(what) + if ('error' in acquired) { + if (!this.deviceSession.isConnected()) return this.explainMissingChannel(what) + this.traceDeviceLink(`${what}: debug channel unavailable — ${acquired.error}`) + return { error: acquired.error, needsReconnect: true } + } + this.traceChannelUse(what, 'debug') + return acquired + } - this.debuggerWebSocketClient = new WebSocketDebugTransport({ - host: connectionParams.ipAddress, - port: 8443, - token: connectionParams.jwtToken, - rejectUnauthorized: false, - }) - await this.debuggerWebSocketClient.connect() - } + private traceChannelUse(what: string, family: 'control' | 'debug'): void { + const link = this.deviceSession.getLink() + this.traceDeviceLink(`${what}: using the ${family} channel (${link?.transport ?? '?'} ${link?.descriptor ?? '?'})`) + } - this.debuggerTargetIp = connectionParams.ipAddress - this.debuggerJwtToken = connectionParams.jwtToken - } else if (connectionType === 'tcp') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + private explainMissingChannel(what: string): ChannelUnavailable { + if (this.deviceSession.isRecovering()) { + this.traceDeviceLink(`${what}: refused, the connection is mid-recovery`) + return { error: 'the connection dropped and is being restored — try again in a moment', needsReconnect: true } + } + this.traceDeviceLink(`${what}: refused, nothing is connected`) + return { error: MainProcessBridge.DEVICE_NOT_CONNECTED, needsReconnect: true } + } - if (!connectionParams.ipAddress) { - return { success: false, error: 'IP address is required for TCP connection' } - } - this.debuggerModbusClient = new ModbusTcpClient({ - host: connectionParams.ipAddress, - port: 502, - timeout: 5000, - }) - await this.debuggerModbusClient.connect() - this.debuggerTargetIp = connectionParams.ipAddress - } else { - if (!connectionParams.port || !connectionParams.baudRate || connectionParams.slaveId === undefined) { - return { success: false, error: 'Port, baud rate, and slave ID are required for RTU connection' } - } + /** + * The emulator boots in milliseconds, but "milliseconds" is not "instantly", and + * its session is opened the instant it starts. + */ + private static readonly SIMULATOR_PROBE_ATTEMPTS = 10 + private static readonly SIMULATOR_PROBE_INTERVAL_MS = 200 - if ( - this.debuggerModbusClient && - this.debuggerConnectionType === 'rtu' && - this.debuggerRtuPort === connectionParams.port && - this.debuggerRtuBaudRate === connectionParams.baudRate && - this.debuggerRtuSlaveId === connectionParams.slaveId - ) { - this.debuggerReconnecting = false - return { success: true } - } + /** + * Reported when a command arrives and no session exists. + * + * Short and neutral on purpose. The caller already says which action failed + * ("Failed to stop PLC: …", "Could not connect to debug target: …"), so this only + * has to supply the reason. It used to explain the reason as well — "the debugger + * and run/stop share the device connection" — which was written for a baremetal + * board and read as nonsense on a Runtime v4, whose debug channel is its own + * WebSocket and shares nothing. Worse, it appeared on a target the user HAD + * connected to, so the explanation was not merely irrelevant but wrong. + */ + private static readonly DEVICE_NOT_CONNECTED = 'not connected to the target' - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + /** + * Open and HOLD the link to a baremetal device (D72). + * + * `candidates` is the ordered list the renderer resolved from the board's debug + * spec — Modbus TCP first when the project enables it, then serial. The manager + * tries them in order and keeps the first that both opens and answers, so a + * stale DHCP address or an unplugged ethernet shield falls through to the cable + * instead of stranding the user on a link that cannot serve a command. + * + * The classification that used to be this method's job now happens per candidate + * (`verifyDeviceCandidate`), because it is also what decides whether a candidate + * is worth keeping. + */ + handleDeviceConnect = async ( + _event: IpcMainInvokeEvent, + candidates: DebugConnectionConfig[], + ): Promise => { + this.deviceLinkProbe = null + this.traceDeviceLink( + `connect requested with ${candidates.length} candidate(s): ${ + candidates.map((config) => `${config.connectionType} ${describeDebugEndpoint(config)}`).join(', ') || '(none)' + }`, + ) - this.debuggerModbusClient = new ModbusRtuClient({ - port: connectionParams.port, - baudRate: connectionParams.baudRate, - slaveId: connectionParams.slaveId, - timeout: 5000, - }) - await this.debuggerModbusClient.connect() - this.debuggerRtuPort = connectionParams.port - this.debuggerRtuBaudRate = connectionParams.baudRate - this.debuggerRtuSlaveId = connectionParams.slaveId - } + const linkCandidates = this.toDeviceLinkCandidates(candidates) + if (linkCandidates.length === 0) { + return { status: 'error', error: 'No usable serial or Modbus TCP connection was configured for this device.' } + } - this.debuggerConnectionType = connectionType - this.debuggerReconnecting = false + const result = await this.deviceSession.open(linkCandidates) + // Read the verdict out of the field after the open: verification runs inside + // it, one candidate at a time, and this is where its conclusion lands. + const probe = this.takeDeviceLinkProbe() + if (result.ok) return probe ?? { status: 'connected-with-firmware' } - return { success: true } - } catch (error) { - this.debuggerModbusClient = null - this.debuggerWebSocketClient = null - this.debuggerTargetIp = null - this.debuggerConnectionType = null - this.debuggerRtuPort = null - this.debuggerRtuBaudRate = null - this.debuggerRtuSlaveId = null - this.debuggerJwtToken = null - return { success: false, error: getErrorMessage(error) } - } + // Nothing worked. A candidate that got far enough to be classified gives the + // better message ("no firmware" beats "could not connect"); otherwise report + // what was tried. + if (probe && probe.status !== 'connected-with-firmware') return probe + const tried = result.attempts.map((attempt) => `${attempt.descriptor}: ${attempt.error}`).join('; ') + return { status: 'no-response', error: tried || 'No connection could be established.' } } - handleDebuggerDisconnect = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } - this.debuggerTargetIp = null - this.debuggerConnectionType = null - this.debuggerRtuPort = null - this.debuggerRtuBaudRate = null - this.debuggerRtuSlaveId = null - this.debuggerJwtToken = null - this.debuggerReconnecting = false - return Promise.resolve({ success: true }) + + /** Close the held link (user pressed Disconnect). */ + handleDeviceDisconnect = async (): Promise<{ success: boolean }> => { + this.deviceSession.close() + this.deviceLinkProbe = null + return { success: true } } handleDebuggerSetVariable = async ( @@ -2080,33 +2275,13 @@ class MainProcessBridge implements MainIpcModule { ): Promise<{ success: boolean; error?: string }> => { const buffer = valueBuffer ? Buffer.from(valueBuffer) : undefined - if (this.debuggerConnectionType === 'websocket') { - if (!this.debuggerWebSocketClient) { - logger.info('[IPC Handler] WebSocket client not connected') - return { success: false, error: 'Not connected to debugger' } - } - - try { - // Shared transport takes Uint8Array; convert from the IPC's - // Buffer payload (Buffer is a Uint8Array subclass so the cast - // is a no-op at runtime, but TS wants the explicit step). - const valueBytes = buffer ? new Uint8Array(buffer) : undefined - const result = await this.debuggerWebSocketClient.setVariable(variableIndex, force, valueBytes) - logger.info('[IPC Handler] WebSocket setVariable result: ' + JSON.stringify(result)) - return result - } catch (error) { - logger.error('[IPC Handler] WebSocket setVariable error: ' + getErrorMessage(error)) - return { success: false, error: getErrorMessage(error) } - } - } - - if (!this.debuggerModbusClient) { - logger.info('[IPC Handler] Modbus client not connected') - return { success: false, error: 'Not connected to debugger' } + const link = await this.requireDebug('write variable') + if ('error' in link) { + return { success: false, error: link.error } } try { - const result = await this.debuggerModbusClient.setVariable(variableIndex, force, buffer) + const result = await link.client.setVariable(variableIndex, force, buffer) logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) return result } catch (error) { @@ -2140,6 +2315,7 @@ class MainProcessBridge implements MainIpcModule { /** Stops the simulator and notifies the renderer so it can update UI state. */ private stopSimulatorAndNotify(): void { if (this.simulatorModule.isRunning()) { + this.closeSimulatorSession() this.simulatorModule.stop() this.mainWindow?.webContents.send('simulator:stopped') } @@ -2368,6 +2544,16 @@ class MainProcessBridge implements MainIpcModule { handleESIMigrateRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => this.wrapServiceCall(() => this.esiService.migrateRepositoryToV2(projectPath)) + /** + * Start the emulator, then open its session. + * + * The running emulator IS the simulator's connection — there is no port to pick + * and no address to configure, so nothing about it is ever resolved from a spec + * or asked of the user. Opening the session here (rather than when the debugger + * asks) is what makes the simulator behave like every other target downstream: + * commands go to a session the manager holds, and when the emulator stops the + * session ends the same way a pulled cable ends a serial one. + */ handleSimulatorLoadFirmware = async ( _event: IpcMainInvokeEvent, hexPath: string, @@ -2376,17 +2562,40 @@ class MainProcessBridge implements MainIpcModule { const fs = await import('fs/promises') const hexContent = await fs.readFile(hexPath, 'utf-8') this.simulatorModule.loadAndRun(hexContent) + + const opened = await this.deviceSession.open( + this.toDeviceLinkCandidates([{ connectionType: 'simulator', connectionParams: {} }]), + ) + if (!opened.ok) { + this.simulatorModule.stop() + const reason = opened.attempts.map((attempt) => attempt.error).join('; ') + return { success: false, error: reason || 'The simulator did not answer its debug protocol' } + } + this.debuggerConnectionType = 'simulator' return { success: true } } catch (error) { return { success: false, error: getErrorMessage(error) } } } + /** + * Stop the emulator entirely — the simulator's Stop button means "stop the + * simulator", not "stop the program it is running". The session closes first so + * the client is dropped before the thing it talks to disappears. + */ handleSimulatorStop = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + this.closeSimulatorSession() this.simulatorModule.stop() return Promise.resolve({ success: true }) } + /** Close the session if it is the simulator's. No-op for any other target. */ + private closeSimulatorSession(): void { + if (this.deviceSession.getLink()?.transport !== 'simulator') return + this.deviceSession.close() + this.debuggerConnectionType = null + } + handleSimulatorIsRunning = (_event: IpcMainInvokeEvent): Promise => { return Promise.resolve(this.simulatorModule.isRunning()) } diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 3fb915a2f..120ff0ccc 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,5 @@ import type { DiscoveredRuntimeDevice, RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { DeviceConnectionStatusPayload } from '@root/middleware/shared/ports/device-port' import type { ESIDevice, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import type { EtherCATRuntimeStatusResponse, @@ -21,6 +22,7 @@ import type { UpdateUserParams, WhoAmIResult, } from '@root/middleware/shared/ports/runtime-port' +import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' import type { PLCProjectData } from '@root/middleware/shared/ports/types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' @@ -347,11 +349,11 @@ const rendererProcessBridge = { } > > => ipcRenderer.invoke('hardware:get-available-boards'), - getAvailableCommunicationPorts: (): Promise<{ name: string; address: string }[]> => + getAvailableCommunicationPorts: (): Promise<{ address: string; boardName?: string; manufacturer?: string }[]> => ipcRenderer.invoke('hardware:get-available-communication-ports'), refreshAvailableBoards: (): Promise<{ board: string; version: string }[]> => ipcRenderer.invoke('hardware:refresh-available-boards'), - refreshCommunicationPorts: (): Promise<{ name: string; address: string }[]> => + refreshCommunicationPorts: (): Promise<{ address: string; boardName?: string; manufacturer?: string }[]> => ipcRenderer.invoke('hardware:refresh-communication-ports'), // ===================== PACKAGE MANAGER METHODS ===================== @@ -404,17 +406,22 @@ const rendererProcessBridge = { ipcRenderer.invoke('util:read-debug-file', projectPath, boardTarget), debuggerVerifyMd5: ( - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, expectedMd5: string, ): Promise<{ success: boolean; match?: boolean; targetMd5?: string; error?: string }> => - ipcRenderer.invoke('debugger:verify-md5', connectionType, connectionParams, expectedMd5), + ipcRenderer.invoke('debugger:verify-md5', expectedMd5), + + /** FC 0x4b run/stop command. Reads come from `onDevicePlcState` (the device + * status poll), not from here. */ + debuggerPlcControl: ( + action: 'run' | 'stop', + ): Promise<{ + success: boolean + state?: number + switchPosition?: number + refusedBySwitch?: boolean + unsupported?: boolean + error?: string + }> => ipcRenderer.invoke('debugger:plc-control', action), debuggerReadProgramStMd5: ( projectPath: string, @@ -440,20 +447,65 @@ const rendererProcessBridge = { ): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('debugger:set-variable', variableIndex, force, valueBuffer), - debuggerConnect: ( - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, - ): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('debugger:connect', connectionType, connectionParams), + debuggerConnect: (): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('debugger:connect'), debuggerDisconnect: (): Promise<{ success: boolean }> => ipcRenderer.invoke('debugger:disconnect'), + // Persistent device connection (D72): try the ordered candidates and HOLD the + // first that answers, returning how the kept channel classified. + deviceConnect: ( + candidates: DebugConnectionConfig[], + ): Promise<{ + status: 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + error?: string + }> => ipcRenderer.invoke('device:connect', candidates), + + // Close the held serial link (Disconnect). + deviceDisconnect: (): Promise<{ success: boolean }> => ipcRenderer.invoke('device:disconnect'), + + // A Runtime v3/v4 session: control over REST at `address`, debug over the channel + // the board declares (opened later, on the debugger's request). + openRuntimeSession: (params: { + address: string + debug: DebugConnectionConfig + }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('session:open-runtime', params), + + closeRuntimeSession: (): Promise<{ success: boolean }> => ipcRenderer.invoke('session:close-runtime'), + + // Upload handoff: give up the link ONLY if it is the serial one holding `port`. + deviceReleaseSerialPort: (port: string | null | undefined): Promise<{ released: boolean }> => + ipcRenderer.invoke('device:release-serial-port', port), + + // Diagnostic trace of the device connection (candidate attempts, poll verdicts, + // which connection served each command), mirrored into the editor console so it + // can be read and copied while reproducing a problem. + onDeviceLinkLog: (callback: (message: string) => void): (() => void) => { + const listener = (_event: unknown, message: string) => callback(message) + ipcRenderer.on('device:link-log', listener) + return () => ipcRenderer.removeListener('device:link-log', listener) + }, + + // Main pushes live link status here (liveness failure, upload/debug handoff). + onDeviceConnectionStatus: (callback: (payload: DeviceConnectionStatusPayload) => void): (() => void) => { + const listener = (_event: unknown, payload: DeviceConnectionStatusPayload) => callback(payload) + ipcRenderer.on('device:connection-status', listener) + return () => ipcRenderer.removeListener('device:connection-status', listener) + }, + + /** + * Subscribe to run/stop state pushed from the held device link. Emitted on + * every liveness tick (FC 0x46 carries the state), so a switch flipped by hand + * at the panel surfaces within one interval without any extra traffic. + */ + onDevicePlcState: ( + callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, + ): (() => void) => { + const listener = (_event: unknown, payload: { port: string; plcState?: number; switchPosition?: number }) => + callback(payload) + ipcRenderer.on('device:plc-state', listener) + return () => ipcRenderer.removeListener('device:plc-state', listener) + }, + // ===================== RUNTIME API METHODS ===================== runtimeGetUsersInfo: (ipAddress: string): Promise<{ hasUsers: boolean; runtimeVersion?: string; error?: string }> => ipcRenderer.invoke('runtime:get-users-info', ipAddress), @@ -503,6 +555,8 @@ const rendererProcessBridge = { overruns: number }> } + /** Run/stop mode-switch position; absent on older runtimes. */ + switchPosition?: 'run' | 'stop' error?: string }> => ipcRenderer.invoke('runtime:get-status', ipAddress, includeStats), runtimeStartPlc: (ipAddress: string): Promise<{ success: boolean; error?: string; status?: string }> => diff --git a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts index 97a36a61a..bbbece386 100644 --- a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts @@ -35,6 +35,7 @@ beforeEach(() => { success: true, content: 'debug_vars[] = { ... }', }), + debuggerPlcControl: jest.fn().mockResolvedValue({ success: true, state: 0 }), } as unknown as typeof window.bridge adapter = createEditorDebuggerAdapter() @@ -46,18 +47,15 @@ beforeEach(() => { describe('connect', () => { it('delegates to bridge with connection type and params', async () => { - const result = await adapter.connect(tcpConfig) + const result = await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('tcp', { - ipAddress: '192.168.1.100', - port: '502', - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() expect(result).toEqual({ success: true }) }) it('sets connected state on success', async () => { expect(adapter.isConnected()).toBe(false) - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) }) @@ -66,55 +64,33 @@ describe('connect', () => { success: false, error: 'Connection refused', }) - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(false) }) it('catches bridge errors', async () => { ;(window.bridge.debuggerConnect as jest.Mock).mockRejectedValue(new Error('IPC failed')) - const result = await adapter.connect(tcpConfig) + const result = await adapter.connect() expect(result).toEqual({ success: false, error: 'IPC failed' }) }) it('supports simulator connection type', async () => { - await adapter.connect({ connectionType: 'simulator', connectionParams: {} }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('simulator', {}) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) it('supports websocket connection type with JWT', async () => { - await adapter.connect({ - connectionType: 'websocket', - connectionParams: { - ipAddress: '10.0.0.1', - port: '8443', - jwtToken: 'my-jwt', - }, - }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('websocket', { - ipAddress: '10.0.0.1', - port: '8443', - jwtToken: 'my-jwt', - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) it('supports RTU connection type with serial params', async () => { - await adapter.connect({ - connectionType: 'rtu', - connectionParams: { - port: '/dev/ttyUSB0', - baudRate: 115200, - slaveId: 1, - }, - }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('rtu', { - port: '/dev/ttyUSB0', - baudRate: 115200, - slaveId: 1, - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) }) @@ -124,7 +100,7 @@ describe('connect', () => { describe('disconnect', () => { it('delegates to bridge', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() const result = await adapter.disconnect() expect(window.bridge.debuggerDisconnect).toHaveBeenCalled() @@ -132,7 +108,7 @@ describe('disconnect', () => { }) it('clears connected state', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) await adapter.disconnect() @@ -145,7 +121,7 @@ describe('disconnect', () => { adapter.onDisconnected(cb1) adapter.onDisconnected(cb2) - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(cb1).toHaveBeenCalledTimes(1) @@ -157,7 +133,7 @@ describe('disconnect', () => { const cb = jest.fn() adapter.onDisconnected(cb) - await adapter.connect(tcpConfig) + await adapter.connect() const result = await adapter.disconnect() expect(adapter.isConnected()).toBe(false) @@ -232,19 +208,35 @@ describe('setVariable', () => { }) }) +// --------------------------------------------------------------------------- +// setPlcState — the operation whose transport parameter caused the bug +// --------------------------------------------------------------------------- + +describe('setPlcState', () => { + it('sends the payload and nothing else', () => { + // Naming a medium here is what made Stop resolve the debug spec, which for a + // DHCP-configured project popped an address dialog — over a serial connection + // that then carried the command anyway. Payload only: the connection manager + // routes it. + void adapter.setPlcState?.('STOPPED') + expect(window.bridge.debuggerPlcControl).toHaveBeenCalledWith('stop') + }) + + it('maps RUNNING to the run action', () => { + void adapter.setPlcState?.('RUNNING') + expect(window.bridge.debuggerPlcControl).toHaveBeenCalledWith('run') + }) +}) + // --------------------------------------------------------------------------- // verifyMd5 // --------------------------------------------------------------------------- describe('verifyMd5', () => { it('delegates to bridge with connection config and expected MD5', async () => { - const result = await adapter.verifyMd5('abc123def456abc123def456abc123de', tcpConfig) + const result = await adapter.verifyMd5('abc123def456abc123def456abc123de') - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith( - 'tcp', - { ipAddress: '192.168.1.100', port: '502' }, - 'abc123def456abc123def456abc123de', - ) + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('abc123def456abc123def456abc123de') expect(result).toEqual({ success: true, match: true, @@ -253,21 +245,24 @@ describe('verifyMd5', () => { }) it('uses different configs per call', async () => { - await adapter.verifyMd5('md5-1', tcpConfig) - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith( - 'tcp', - { ipAddress: '192.168.1.100', port: '502' }, - 'md5-1', - ) + await adapter.verifyMd5('md5-1') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-1') const simConfig: DebugConnectionConfig = { connectionType: 'simulator', connectionParams: {} } - await adapter.verifyMd5('md5-2', simConfig) - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('simulator', {}, 'md5-2') + await adapter.verifyMd5('md5-2') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-2') + }) + + it('omits the transport for a target the connection manager already holds', async () => { + // A connected baremetal board: naming a medium here is what made a debug start + // over serial ask for a DHCP address. + await adapter.verifyMd5('md5-held') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-held') }) it('catches bridge errors', async () => { ;(window.bridge.debuggerVerifyMd5 as jest.Mock).mockRejectedValue(new Error('MD5 check failed')) - const result = await adapter.verifyMd5('abc123', tcpConfig) + const result = await adapter.verifyMd5('abc123') expect(result).toEqual({ success: false, error: 'MD5 check failed' }) }) @@ -328,7 +323,7 @@ describe('onDisconnected', () => { const cb = jest.fn() const unsub = adapter.onDisconnected(cb) - await adapter.connect(tcpConfig) + await adapter.connect() unsub() await adapter.disconnect() @@ -344,7 +339,7 @@ describe('onDisconnected', () => { adapter.onDisconnected(cb3) unsub2() - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(cb1).toHaveBeenCalledTimes(1) @@ -372,12 +367,12 @@ describe('isConnected', () => { }) it('returns true after successful connect', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) }) it('returns false after disconnect', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(adapter.isConnected()).toBe(false) }) diff --git a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts index a2a79eb59..16bf956b4 100644 --- a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts @@ -15,10 +15,7 @@ const mockBoards = new Map([ ], ]) -const mockPorts: CommunicationPort[] = [ - { name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }, - { name: '/dev/ttyACM0', address: '/dev/ttyACM0' }, -] +const mockPorts: CommunicationPort[] = [{ address: '/dev/ttyUSB0' }, { address: '/dev/ttyACM0' }] const mockRefreshResult = [{ board: 'Arduino Uno', version: '1.8.6' }] @@ -29,6 +26,9 @@ beforeEach(() => { refreshAvailableBoards: jest.fn().mockResolvedValue(mockRefreshResult), refreshCommunicationPorts: jest.fn().mockResolvedValue(mockPorts), getPreviewImage: jest.fn().mockResolvedValue('data:image/png;base64,abc123'), + deviceConnect: jest.fn().mockResolvedValue({ status: 'connected-with-firmware' }), + deviceDisconnect: jest.fn().mockResolvedValue({ success: true }), + onDeviceConnectionStatus: jest.fn().mockReturnValue(() => undefined), } as unknown as typeof window.bridge }) @@ -73,4 +73,29 @@ describe('createEditorDeviceAdapter', () => { await adapter.getPreviewImage('motor-shield.png', '/path/to/pkg') expect(window.bridge.getPreviewImage).toHaveBeenCalledWith('motor-shield.png', '/path/to/pkg') }) + + it('delegates connect to window.bridge with the candidate list', async () => { + // Connect passes every way to reach the device, in order; the main process + // tries them and keeps the first that answers. + const candidates = [ + { connectionType: 'tcp' as const, connectionParams: { ipAddress: '192.168.0.50' } }, + { connectionType: 'rtu' as const, connectionParams: { port: 'COM5', baudRate: 115200 } }, + ] + const result = await adapter.connect(candidates) + expect(window.bridge.deviceConnect).toHaveBeenCalledWith(candidates) + expect(result).toMatchObject({ status: 'connected-with-firmware' }) + }) + + it('delegates disconnect to window.bridge', async () => { + const result = await adapter.disconnect() + expect(window.bridge.deviceDisconnect).toHaveBeenCalledTimes(1) + expect(result).toEqual({ success: true }) + }) + + it('delegates onConnectionStatus subscription to window.bridge and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onConnectionStatus(cb) + expect(window.bridge.onDeviceConnectionStatus).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) }) diff --git a/src/middleware/adapters/editor/debugger-adapter.ts b/src/middleware/adapters/editor/debugger-adapter.ts index 169a0f16f..864f63191 100644 --- a/src/middleware/adapters/editor/debugger-adapter.ts +++ b/src/middleware/adapters/editor/debugger-adapter.ts @@ -10,10 +10,10 @@ * auto-reconnection using stored connection parameters. */ +import type { PlcControlResult } from '../../../backend/shared/debug/types' import { getErrorMessage } from '../../../frontend/utils/get-error-message' import type { DebuggerPort } from '../../shared/ports/debugger-port' import type { - DebugConnectionConfig, DebugSetResult, DebugVariableResult, Md5VerifyResult, @@ -25,13 +25,15 @@ export function createEditorDebuggerAdapter(): DebuggerPort { const disconnectCallbacks: Array<() => void> = [] return { - async connect(config: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> { + async connect(): Promise<{ success: boolean; error?: string }> { try { - const result = await window.bridge.debuggerConnect(config.connectionType, config.connectionParams) + // Nothing to pass: the connection manager already holds this target's + // session, so there is no medium for the caller to name. + const result = await window.bridge.debuggerConnect() if (result.success) connected = true return result - } catch (err) { - return { success: false, error: getErrorMessage(err) } + } catch (error) { + return { success: false, error: getErrorMessage(error) } } }, @@ -64,11 +66,20 @@ export function createEditorDebuggerAdapter(): DebuggerPort { } }, - async verifyMd5(expectedMd5: string, config: DebugConnectionConfig): Promise { + async verifyMd5(expectedMd5: string): Promise { try { - return await window.bridge.debuggerVerifyMd5(config.connectionType, config.connectionParams, expectedMd5) - } catch (err) { - return { success: false, error: getErrorMessage(err) } + return await window.bridge.debuggerVerifyMd5(expectedMd5) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + }, + + async setPlcState(state: 'RUNNING' | 'STOPPED'): Promise { + try { + // Payload only. Which medium carries it is the connection manager's business. + return await window.bridge.debuggerPlcControl(state === 'RUNNING' ? 'run' : 'stop') + } catch (error) { + return { success: false, error: getErrorMessage(error) } } }, diff --git a/src/middleware/adapters/editor/device-adapter.ts b/src/middleware/adapters/editor/device-adapter.ts index 9e59afadf..000f0d69f 100644 --- a/src/middleware/adapters/editor/device-adapter.ts +++ b/src/middleware/adapters/editor/device-adapter.ts @@ -14,8 +14,12 @@ * util:get-preview-image (invoke) */ -import type { DevicePort } from '../../shared/ports/device-port' -import type { BoardInfo, CommunicationPort } from '../../shared/ports/types' +import type { + DeviceConnectionStatusPayload, + DeviceConnectResult, + DevicePort, +} from '../../shared/ports/device-port' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from '../../shared/ports/types' export function createEditorDeviceAdapter(): DevicePort { return { @@ -38,5 +42,43 @@ export function createEditorDeviceAdapter(): DevicePort { getPreviewImage(imageName: string, packagePath?: string): Promise { return window.bridge.getPreviewImage(imageName, packagePath) }, + + connect(candidates: DebugConnectionConfig[]): Promise { + return window.bridge.deviceConnect(candidates) + }, + + openRuntimeSession(params: { address: string; debug: DebugConnectionConfig }): Promise<{ + success: boolean + error?: string + }> { + return window.bridge.openRuntimeSession(params) + }, + + closeRuntimeSession(): Promise<{ success: boolean }> { + return window.bridge.closeRuntimeSession() + }, + + async releaseSerialPort(port: string | null | undefined): Promise { + const result = await window.bridge.deviceReleaseSerialPort(port) + return result.released + }, + + disconnect(): Promise<{ success: boolean }> { + return window.bridge.deviceDisconnect() + }, + + onLinkLog(callback: (message: string) => void): () => void { + return window.bridge.onDeviceLinkLog(callback) + }, + + onConnectionStatus(callback: (payload: DeviceConnectionStatusPayload) => void): () => void { + return window.bridge.onDeviceConnectionStatus(callback) + }, + + onPlcState( + callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, + ): () => void { + return window.bridge.onDevicePlcState(callback) + }, } } diff --git a/src/middleware/shared/ports/debugger-port.ts b/src/middleware/shared/ports/debugger-port.ts index 5a1bd8830..530ecfc00 100644 --- a/src/middleware/shared/ports/debugger-port.ts +++ b/src/middleware/shared/ports/debugger-port.ts @@ -28,15 +28,18 @@ * - DebugTransport interface implementations */ -import type { DebugConnectionConfig, DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from './types' +import type { PlcControlResult } from '../../../backend/shared/debug/types' +import type { DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from './types' export interface DebuggerPort { /** - * Connect to a debug target. - * @param config — Connection target (TCP host, RTU serial, WebSocket, or simulator). - * The adapter maps this to the platform's transport mechanism. + * Start a debug session over the session the connection manager holds. + * + * Takes nothing: every target's session is established before this — a device by + * Connect, a runtime by logging in, the simulator by starting. Naming a medium + * here is what made a Stop over serial ask for a DHCP address. */ - connect(config: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> + connect(): Promise<{ success: boolean; error?: string }> /** Disconnect from the current debug target. */ disconnect(): Promise<{ success: boolean }> @@ -61,7 +64,7 @@ export interface DebuggerPort { * Used to detect program mismatch before starting a debug session. * @param config — Connection target used for the verification request. */ - verifyMd5(expectedMd5: string, config: DebugConnectionConfig): Promise + verifyMd5(expectedMd5: string): Promise /** * Read the MD5 hash of the compiled ST program from the debug artifacts. @@ -86,6 +89,19 @@ export interface DebuggerPort { */ onDisconnected(callback: () => void): Unsubscribe + /** + * Ask the target to run or stop (Modbus FC 0x4b). + * + * Command only — the state is READ from the device status poll (FC 0x46), + * which already reports it, so there is deliberately no `getPlcState` here. + * + * A RUN request is REFUSED, not queued, while the hardware mode switch reads + * STOP; the result carries `refusedBySwitch` so the caller shows the "flip the + * switch to RUN" warning. `unsupported` means the firmware predates the + * run/stop state machine. + */ + setPlcState?(state: 'RUNNING' | 'STOPPED'): Promise + /** Check if the debugger is currently connected. */ isConnected(): boolean } diff --git a/src/middleware/shared/ports/device-port.ts b/src/middleware/shared/ports/device-port.ts index c6606533a..98538d72d 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -21,7 +21,57 @@ * - getDeviceStatus() */ -import type { BoardInfo, CommunicationPort } from './types' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from './types' + +// --------------------------------------------------------------------------- +// Connect-time classification (D72) — platform contract shared by the port and +// its editor adapter. The store can't reach into `backend/`, so the canonical +// shape lives here. +// --------------------------------------------------------------------------- + +/** How a freshly-opened channel classified. */ +export type DeviceProbeStatus = 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + +/** + * Result of opening a persistent device link (D72): how the channel the main + * process settled on classified. + */ +export interface DeviceConnectResult { + status: DeviceProbeStatus + /** Transport failure text when `status === 'error'`. */ + error?: string +} + +/** + * Live status of the held baremetal serial link, pushed by the main process. + * + * `reason: 'lost'` distinguishes the one failure the user must be TOLD about — a + * link that was up, died, and could not be recovered — from an 'error' that came + * straight out of something they just clicked (which already has its own dialog). + */ +export interface DeviceConnectionStatusPayload { + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** + * Medium the CONTROL channel uses (or was using, when it dropped). Absent for a + * REST-controlled runtime session: REST holds no connection. + */ + transport?: 'rtu' | 'tcp' | 'simulator' + /** + * Medium the DEBUG channel uses — the same as `transport` when one channel serves + * both roles, else `websocket` (v4) or `tcp` (v3). Consumers that must size work + * to the wire (the debug poll's frame budget) read THIS, rather than inferring a + * medium they have no business choosing. + */ + debugTransport?: 'rtu' | 'tcp' | 'simulator' | 'websocket' + /** + * The endpoint, as the user would name it: a serial path ("/dev/ttyACM0", + * "COM5") or an IP address. Not called `port`, because for a Modbus TCP link it + * is an address — and a name that implies serial is what led callers to branch + * on the wrong thing in the first place. + */ + descriptor?: string + reason?: 'lost' +} export interface DevicePort { /** @@ -59,4 +109,78 @@ export interface DevicePort { * Web: returns URL to bundled image asset. */ getPreviewImage(imageName: string, packagePath?: string): Promise + + /** + * Open and HOLD the connection to a baremetal device (D72). + * + * `candidates` is the ordered list of ways to reach it, resolved from the + * board's debug spec: Modbus TCP first when the project enables it, then serial. + * The main process tries them in order and keeps the first that both opens and + * answers, so a stale DHCP address or an unplugged ethernet shield falls through + * to the cable instead of leaving the editor claiming a connection it does not + * have. It then holds that ONE connection — every command (debug, run/stop, the + * status poll) rides it — polls it, and pushes status changes through + * `onConnectionStatus`. + * + * Editor: `device:connect`. Web: not applicable locally. + */ + connect(candidates: DebugConnectionConfig[]): Promise + + /** + * Establish a session with a target CONTROLLED over REST (Runtime v3/v4), after + * the renderer has logged in: `debug` describes the channel that target debugs + * over (v3 Modbus TCP, v4 the WebSocket), which is opened later, only if a debug + * session asks for it. + * + * Editor: `session:open-runtime`. Web: no-op. + */ + openRuntimeSession?(params: { address: string; debug: DebugConnectionConfig }): Promise<{ + success: boolean + error?: string + }> + + /** Close a REST-controlled session (logout / disconnect). */ + closeRuntimeSession?(): Promise<{ success: boolean }> + + /** + * Hand the serial port over for an upload: releases the held connection only if + * it IS the serial one occupying `port`, and reports whether it did. + * + * A connection over Modbus TCP is left alone — flashing over USB does not + * disturb it, so debugging and run/stop survive the upload. Disconnecting + * unconditionally (what the upload flow used to do) threw away a working link + * for no reason. + * + * Editor: `device:release-serial-port`. Web: no-op, returns false. + */ + releaseSerialPort(port: string | null | undefined): Promise + + /** Close a held serial link. Editor: `device:disconnect`. */ + disconnect(): Promise<{ success: boolean }> + + /** + * Subscribe to live serial-link status pushed by the main process (liveness + * failure, upload/debug handoff). Returns an unsubscribe function. Editor: + * `device:connection-status` IPC event. Web: no-op. + */ + /** + * Subscribe to the device connection's diagnostic trace. Returns an unsubscribe + * function. Editor: `device:link-log`. Web: no-op. + */ + onLinkLog?(callback: (message: string) => void): () => void + + onConnectionStatus(callback: (payload: DeviceConnectionStatusPayload) => void): () => void + + /** + * Subscribe to run/stop state from the held device link (baremetal targets). + * + * Pushed on the same liveness tick that keeps the link honest — the status + * frame (FC 0x46) carries the run/stop state and the mode-switch position — so + * this costs no extra round trip and needs no second timer. `plcState` is + * 0/1/2 (STOPPED/RUNNING/ERROR); `switchPosition` is 0/1 (STOP/RUN) and is + * absent on firmware predating the run/stop state machine. + */ + onPlcState?( + callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, + ): () => void } diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index 12d3e51b1..dd021eeaf 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -116,6 +116,11 @@ export interface RuntimeStatusResult { success: boolean status?: PlcStatus | (string & {}) timingStats?: TimingStats + /** Run/stop mode-switch position reported by the runtime (`'run'` / + * `'stop'`). Devices with no switch-aware VPP plugin always report + * `'run'`, and runtimes older than this field omit it entirely — treat + * `undefined` as "no gating". */ + switchPosition?: 'run' | 'stop' error?: string } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 090044a9f..8715b9010 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -915,9 +915,25 @@ export interface VendorIoMapping { entries: IoMappingEntry[] } +/** + * A serial port offered in the communication-port picker. + * + * Deliberately NOT a pre-composed display string. The producer reports facts + * and the renderer decides how they read (`serialPortDisplay`) — conflating the + * two is what let the board name get dropped: the label had to guess whether + * `name` held a bare manufacturer or an already-composed `"COM5 (Arduino Uno)"`. + */ export interface CommunicationPort { - name: string + /** OS-canonical port identifier, and the value actually opened: `COM5` on + * Windows, `/dev/ttyUSB0` on Linux, `/dev/cu.usbmodem*` on macOS. Always the + * primary label — never replaced by a descriptor. */ address: string + /** Board name identified by arduino-cli from the connected core's VID/PID + * (e.g. `Arduino MKR`). Absent when no core matched the device. */ + boardName?: string + /** Manufacturer / vendor string from `serialport` (e.g. `wch.cn` for a + * CH340). The fallback descriptor when arduino-cli identified no board. */ + manufacturer?: string } export interface SerialPort { diff --git a/src/middleware/shared/utils/debug-endpoint.ts b/src/middleware/shared/utils/debug-endpoint.ts new file mode 100644 index 000000000..1916adc06 --- /dev/null +++ b/src/middleware/shared/utils/debug-endpoint.ts @@ -0,0 +1,15 @@ +import type { DebugConnectionConfig } from '../ports/types' + +/** + * How a connection endpoint reads to a user: a serial path ("/dev/ttyACM0", + * "COM5") or an IP address. + * + * Shared between the main process (which labels the connection it holds) and the + * renderer (which names endpoints in dialogs), so "could not reach X" and the + * status bar always spell X the same way. + */ +export function describeDebugEndpoint(config: DebugConnectionConfig): string { + if (config.connectionType === 'tcp') return String(config.connectionParams.ipAddress ?? 'the configured IP address') + if (config.connectionType === 'simulator') return 'simulator' + return String(config.connectionParams.port ?? 'the selected port') +} diff --git a/src/middleware/shared/utils/target-capabilities/presets.ts b/src/middleware/shared/utils/target-capabilities/presets.ts index 7fe88efb1..80f1bd18e 100644 --- a/src/middleware/shared/utils/target-capabilities/presets.ts +++ b/src/middleware/shared/utils/target-capabilities/presets.ts @@ -30,6 +30,7 @@ export const SIMULATOR_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: true, hasRuntimeStats: false, isInProcessSimulator: true, + plcStateControl: false, directUsbUpload: true, } @@ -46,6 +47,7 @@ export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: false, + plcStateControl: false, directUsbUpload: false, } @@ -64,6 +66,7 @@ export const RUNTIME_V4_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: true, isInProcessSimulator: false, + plcStateControl: true, directUsbUpload: false, } @@ -83,5 +86,6 @@ export const ARDUINO_CLI_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: true, hasRuntimeStats: false, isInProcessSimulator: false, + plcStateControl: true, directUsbUpload: true, } diff --git a/src/middleware/shared/utils/target-capabilities/resolve.ts b/src/middleware/shared/utils/target-capabilities/resolve.ts index 186e1e370..740806caf 100644 --- a/src/middleware/shared/utils/target-capabilities/resolve.ts +++ b/src/middleware/shared/utils/target-capabilities/resolve.ts @@ -49,6 +49,7 @@ const EMPTY_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: false, + plcStateControl: false, directUsbUpload: false, } diff --git a/src/middleware/shared/utils/target-capabilities/types.ts b/src/middleware/shared/utils/target-capabilities/types.ts index d88f88954..1572c367f 100644 --- a/src/middleware/shared/utils/target-capabilities/types.ts +++ b/src/middleware/shared/utils/target-capabilities/types.ts @@ -93,6 +93,13 @@ export interface TargetCapabilities { * whether the host *can* run a simulator. */ isInProcessSimulator: boolean + /** Target implements the runtime run/stop state machine, so the + * Start/Stop control is meaningful. Runtime v4 drives it over REST; + * arduino-cli targets drive it over the debugger transport (Modbus + * FC 0x49). Runtime v3 has its own web UI and is excluded; the + * Simulator keeps its dedicated start/stop path. */ + plcStateControl: boolean + /** Upload happens over a local connection (USB / loopback) and * doesn't require a separate "Connect" step. Arduino-CLI + the * in-process Simulator. Runtime v3 / v4 require an established From 8ac77750c84429209e16aee72759a53eb8fc8b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 12:18:09 +0200 Subject: [PATCH 20/79] style: prettier + import sort over the connection surface Formatting only, no behaviour change. Also drops three imports left unused by the licensing removal (Popover/Copy in board.tsx, DebugBoardIdResult in the WebSocket transport) and normalises two files to LF. Co-Authored-By: Claude Opus 5 --- .../__tests__/device-session-manager.test.ts | 16 +- .../shared/compile/steps/generate-defines.ts | 11 +- .../shared/debug/__tests__/modbus-pdu.test.ts | 9 +- .../shared/debug/websocket-debug-transport.ts | 9 +- .../connect-resolve-regression.test.ts | 51 +++- .../__tests__/modbus-rtu-client.test.ts | 14 +- .../shared/simulator/modbus-rtu-client.ts | 4 +- .../editor/device/configuration/board.tsx | 152 ++++++----- .../workspace-activity-bar/default.tsx | 37 ++- src/frontend/hooks/useDebugSession.ts | 243 +++++++++--------- .../store/__tests__/device-slice.test.ts | 42 ++- .../store/__tests__/device-types.test.ts | 2 +- .../utils/__tests__/serial-port-label.test.ts | 7 +- .../utils/vpp/__tests__/field-options.test.ts | 13 +- src/main/modules/ipc/main.ts | 21 +- .../adapters/editor/debugger-adapter.ts | 7 +- .../adapters/editor/device-adapter.ts | 10 +- src/middleware/shared/ports/device-port.ts | 4 +- 18 files changed, 351 insertions(+), 301 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts index 7c964bc5d..7b0801ffd 100644 --- a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -278,9 +278,7 @@ describe('DeviceSessionManager', () => { expect(h.manager.isConnected()).toBe(false) expect(h.manager.isRecovering()).toBe(false) - expect(h.statuses).toEqual([ - { status: 'error', transport: 'rtu', descriptor: '/dev/ttyUSB0', reason: 'lost' }, - ]) + expect(h.statuses).toEqual([{ status: 'error', transport: 'rtu', descriptor: '/dev/ttyUSB0', reason: 'lost' }]) }) }) @@ -388,7 +386,11 @@ describe('DeviceSessionManager', () => { const h = harness() h.manager.openRestSession({ address: '10.0.0.5', - debugChannel: { transport: 'websocket', descriptor: 'websocket 10.0.0.5', create: () => asTransport(new FakeClient()) }, + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 10.0.0.5', + create: () => asTransport(new FakeClient()), + }, }) expect(h.manager.isConnected()).toBe(true) @@ -406,7 +408,11 @@ describe('DeviceSessionManager', () => { const h = harness() h.manager.openRestSession({ address: '10.0.0.5', - debugChannel: { transport: 'websocket', descriptor: 'websocket 10.0.0.5', create: () => asTransport(new FakeClient()) }, + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 10.0.0.5', + create: () => asTransport(new FakeClient()), + }, }) expect(h.statuses.at(-1)).toEqual({ diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index 0510cbb86..61445cc95 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -104,8 +104,15 @@ export interface GenerateDefinesInput { * editor-produced and web-produced firmware comes out clean). */ export function generateDefinesContent(input: GenerateDefinesInput): string { - const { boardEntry, devicePinMapping, stProgramFileContent, buildMD5Hash, boardRuntime, vppModbusState, defaultSerial } = - input + const { + boardEntry, + devicePinMapping, + stProgramFileContent, + buildMD5Hash, + boardRuntime, + vppModbusState, + defaultSerial, + } = input let DEFINES_CONTENT = '' diff --git a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts index c65770c91..5daee4a1a 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts @@ -287,7 +287,14 @@ describe('buildGetVersionRequest / parseGetVersionResponse', () => { }) it('strips a trailing NUL terminator', () => { - const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION, ModbusDebugResponse.SUCCESS, 0x31, 0x2e, 0x30, 0x00]) + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_VERSION, + ModbusDebugResponse.SUCCESS, + 0x31, + 0x2e, + 0x30, + 0x00, + ]) expect(parseGetVersionResponse(buf).version).toBe('1.0') }) diff --git a/src/backend/shared/debug/websocket-debug-transport.ts b/src/backend/shared/debug/websocket-debug-transport.ts index 44b62678f..4192c0df3 100644 --- a/src/backend/shared/debug/websocket-debug-transport.ts +++ b/src/backend/shared/debug/websocket-debug-transport.ts @@ -31,14 +31,7 @@ import { parseGetMd5Response, parseSetVariableResponse, } from './modbus-pdu' -import type { - DebugBoardIdResult, - DebugSetResult, - DebugTransport, - DebugTransportResult, - DeviceDebugChannel, - Md5ProbeResult, -} from './types' +import type { DebugSetResult, DebugTransport, DebugTransportResult, DeviceDebugChannel, Md5ProbeResult } from './types' const REQUEST_TIMEOUT_MS = 5000 const CONNECT_TIMEOUT_MS = 5000 diff --git a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts index 783414209..e2c285995 100644 --- a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts +++ b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts @@ -29,7 +29,10 @@ const ARDUINO_TRANSPORTS = ['modbus-serial', 'modbus-tcp'] as const * before the user picks one — the builder omits the key entirely. */ const disconnectedUsbContext = (port?: string): DebugResolverContext => ({ state: { - configuration: { deviceBoard: 'AutomationDirect P1AM-100', ...(port !== undefined ? { communicationPort: port } : {}) }, + configuration: { + deviceBoard: 'AutomationDirect P1AM-100', + ...(port !== undefined ? { communicationPort: port } : {}), + }, screens: { modbus_rtu: { enabled: true, rtu_baud_rate: '115200', rtu_slave_id: 1 } }, runtimeConnection: {}, promptCache: {}, @@ -119,7 +122,9 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('offers BOTH transports, SERIAL first, when the project enables Modbus TCP', () => { // Serial leads: it is the direct, local path, with no address to be stale and // nothing to ask the user. Modbus TCP is the remote fallback. - const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }) + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) expect(result.kind).toBe('candidates') if (result.kind !== 'candidates') return @@ -132,7 +137,9 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { // debugger keeps the serial protocol compiled into every baremetal firmware. // Requiring `enabledWhen` here is what made Connect refuse a Modbus-TCP-only // project with "select a communication port" while one was plainly selected. - const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }) + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) if (result.kind !== 'candidates') throw new Error('expected candidates') expect(result.candidates.some((candidate) => candidate.config.connectionType === 'rtu')).toBe(true) }) @@ -145,7 +152,6 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { expect(rtuOnly.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) }) - it('resolves a Runtime v4 target, whose only transport is a WebSocket', () => { // The regression that broke every v4 target: with eligibility hardcoded to // serial-then-TCP, a `websocket` channel was never a candidate, so no session @@ -222,7 +228,10 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('lets a caller skip a channel it has decided against', () => { // Channel 0 in this spec is the TCP one. - const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS], skipChannels: [0] }) + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + skipChannels: [0], + }) if (result.kind !== 'candidates') throw new Error('expected candidates') expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) }) @@ -257,7 +266,10 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('sets the DHCP channel aside instead of asking, when prompts are deferred', () => { // The user's report: with DHCP on, Connect hung on a dialog before trying // anything. With a cable attached, that question is pure interruption. - const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) expect(result.kind).toBe('candidates') if (result.kind !== 'candidates') return @@ -267,10 +279,16 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('asks once the caller resolves that channel on its own', () => { // The second pass, run only after everything silent has failed. - const deferred = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + const deferred = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) if (deferred.kind !== 'candidates') throw new Error('expected candidates') - const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], onlyChannels: deferred.awaitingInput }) + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + onlyChannels: deferred.awaitingInput, + }) expect(result.kind).toBe('prompt') }) @@ -280,7 +298,10 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { // exactly what is missing. const context = dhcpContext() delete context.state.configuration.communicationPort - const result = resolveDeviceLinkCandidates(dhcpSpec, context, { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + const result = resolveDeviceLinkCandidates(dhcpSpec, context, { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) expect(result.kind).toBe('candidates') if (result.kind !== 'candidates') return @@ -291,14 +312,20 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('still reports a missing port when serial is the only candidate', () => { // Candidate resolution must not swallow a channel's own `required` message. - const result = resolveDeviceLinkCandidates(baremetalSpec, disconnectedUsbContext(), { transports: [...ARDUINO_TRANSPORTS] }) + const result = resolveDeviceLinkCandidates(baremetalSpec, disconnectedUsbContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) expect(result).toMatchObject({ kind: 'error', body: 'No serial port selected.' }) }) it('reports unsupported when the board declares nothing reachable', () => { const malformed = {} as unknown as DebugSpec - expect(resolveDeviceLinkCandidates(malformed, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe('unsupported') - expect(resolveDeviceLinkCandidates(undefined, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe('unsupported') + expect(resolveDeviceLinkCandidates(malformed, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe( + 'unsupported', + ) + expect(resolveDeviceLinkCandidates(undefined, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe( + 'unsupported', + ) }) it('shows why a precondition cannot express a debugger-only requirement', () => { diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 8f5e6edc1..15a85d885 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -604,7 +604,9 @@ describe('ModbusRtuClient', () => { it('returns error on incomplete success payload', async () => { await connectClient() - autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([ModbusDebugResponse.SUCCESS, 1]))) + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([ModbusDebugResponse.SUCCESS, 1])), + ) const result = await client.getStatus() expect(result.success).toBe(false) expect(result.error).toContain('Incomplete status response') @@ -701,7 +703,9 @@ describe('ModbusRtuClient', () => { it('handles id_len = 0 (unsupported core) as success with empty id', async () => { await connectClient() - autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00]))) + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00])), + ) const result = await client.getBoardId() expect(result.success).toBe(true) expect(result.boardIdHex).toBe('') @@ -727,7 +731,11 @@ describe('ModbusRtuClient', () => { it('returns error on incomplete id data', async () => { await connectClient() autoRespond( - buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b])), + buildResponse( + 1, + ModbusFunctionCode.DEBUG_GET_BOARD_ID, + new Uint8Array([ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b]), + ), ) const result = await client.getBoardId() expect(result.success).toBe(false) diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts index 37d3c19f5..8de172813 100644 --- a/src/backend/shared/simulator/modbus-rtu-client.ts +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -581,9 +581,7 @@ export class ModbusRtuClient { // buildPlcSetStateRequest returns [FC][state]; assembleRequest writes the // FC + slaveId itself, so hand it only the trailing payload. const pdu = buildPlcSetStateRequest(state) - const response = await this.sendRequest( - this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, pdu.subarray(1)), - ) + const response = await this.sendRequest(this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, pdu.subarray(1))) if (response.length < 8) { return { success: false, error: `Invalid response: too short (${response.length} bytes)` } } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index d4b8c9f15..6a14c8240 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -1,9 +1,7 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ -import * as Popover from '@radix-ui/react-popover' import type { TimingStats } from '@root/middleware/shared/ports/types' import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' -import { Copy } from 'lucide-react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifier' @@ -35,9 +33,7 @@ import { PinMappingTable } from './components/pin-mapping-table' */ function DeviceConnectedIndicator({ isConnected }: { isConnected: boolean }) { if (!isConnected) return null - return ( - Connected - ) + return Connected } const Board = memo(function () { @@ -650,81 +646,81 @@ const Board = memo(function () { ) : capabilities.hasLocalSerialPorts ? ( <> -
- - - {availableCommunicationPorts.map((port) => { - // Label by the OS-canonical port path (COM5 / /dev/ttyUSB0 / - // /dev/tty.usbserial-*); the chip/vendor name rides as a hover hint. - const { label, title } = serialPortDisplay(port) - return ( - - - {label} - - - ) - })} - - - +
+ - - -
- - - + + ) : null} {!isOpenPLCRuntimeTarget(currentBoardInfo) && !isSimulatorTarget(currentBoardInfo) && ( diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 43c62094c..df4f97cd6 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -509,19 +509,16 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa * package provides it, so a P1AM says "CPU switch" rather than the generic * wording. */ - const warnSwitchInStop = useCallback( - async (deviceName: string, switchLabel?: string): Promise => { - await showDeviceDialog( - 'warning', - 'Device is in STOP', - `The ${switchLabel ?? 'mode switch'} on ${deviceName} is in the STOP position. ` + - 'The PLC cannot be started from the editor while the switch is in STOP.\n\n' + - 'Flip the switch to RUN and try again.', - ['OK'], - ) - }, - [], - ) + const warnSwitchInStop = useCallback(async (deviceName: string, switchLabel?: string): Promise => { + await showDeviceDialog( + 'warning', + 'Device is in STOP', + `The ${switchLabel ?? 'mode switch'} on ${deviceName} is in the STOP position. ` + + 'The PLC cannot be started from the editor while the switch is in STOP.\n\n' + + 'Flip the switch to RUN and try again.', + ['OK'], + ) + }, []) const handlePlcControl = useCallback(async (): Promise => { const boardTarget = deviceDefinitions.configuration.deviceBoard @@ -529,9 +526,8 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const caps = resolveTargetCapabilities(boardInfo) if (!caps.plcStateControl) return - const switchLabel = ( - boardInfo as { stateControl?: { modeSwitch?: { label?: string } } } | undefined - )?.stateControl?.modeSwitch?.label + const switchLabel = (boardInfo as { stateControl?: { modeSwitch?: { label?: string } } } | undefined)?.stateControl + ?.modeSwitch?.label // ONE path for every target. "Start the PLC" is the same request whether it // travels as Modbus FC 0x4b down a cable or as an HTTP POST to a runtime; the @@ -908,7 +904,8 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa canEdit, executeSave, addLog, - currentBoardInfo]) + currentBoardInfo, + ]) // --------------------------------------------------------------------------- // JSX @@ -980,11 +977,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa > void handleSimulatorControl() : () => void handlePlcControl()} - disabled={ - isSimulatorBoard - ? isCompiling || isDebuggerProcessing - : plcControlBlocked - } + disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : plcControlBlocked} className={cn( isSimulatorBoard ? isCompiling || isDebuggerProcessing diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index 99d70ed4b..8581b85a0 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -59,152 +59,149 @@ export function useDebugSession(): UseDebugSessionReturn { const debugTreesRef = useRef>({}) - const connectAndStart = useCallback( - async (): Promise<{ success: boolean; error?: string }> => { - const { project, workspaceActions: wsActions, consoleActions: logActions } = useOpenPLCStore.getState() - const boardTarget = deviceDefinitions.configuration.deviceBoard - const projectPath = project.meta.path + const connectAndStart = useCallback(async (): Promise<{ success: boolean; error?: string }> => { + const { project, workspaceActions: wsActions, consoleActions: logActions } = useOpenPLCStore.getState() + const boardTarget = deviceDefinitions.configuration.deviceBoard + const projectPath = project.meta.path - logActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Connecting debugger...' }) + logActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Connecting debugger...' }) - try { - const debugFileResult = await debuggerPort.readDebugFile(projectPath, boardTarget) - if (!debugFileResult.success || !debugFileResult.content) { - const error = `Failed to read debug-map.json: ${debugFileResult.error ?? 'No content'}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } - } + try { + const debugFileResult = await debuggerPort.readDebugFile(projectPath, boardTarget) + if (!debugFileResult.success || !debugFileResult.content) { + const error = `Failed to read debug-map.json: ${debugFileResult.error ?? 'No content'}` + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } + } - wsActions.setDebugCContent(debugFileResult.content) + wsActions.setDebugCContent(debugFileResult.content) - const instances = project.data.configurations.resource.instances + const instances = project.data.configurations.resource.instances - const debugMap = parseDebugMap(debugFileResult.content) - if (!debugMap) { - const error = 'Invalid debug-map.json (expected schema version 2)' - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } + const debugMap = parseDebugMap(debugFileResult.content) + if (!debugMap) { + const error = 'Invalid debug-map.json (expected schema version 2)' + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } + } + + const entriesForTree = debugMapToEntries(debugMap) + logActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `Debug map: ${debugMap.leaves.length} leaves across ${debugMap.arrays.length} arrays.`, + }) + + // Build the debug variable tree — the single enumeration walk. The + // composite-key → index map (used by the LD/FBD editors and the poller) + // is derived from this same tree, so every consumer resolves a + // variable's address identically. + let treeMap = new Map() + const pouTrees: Record = {} + try { + const treeResult = buildDebugVariableTreeMap( + project.data.pous, + instances, + entriesForTree, + project.data, + useOpenPLCStore.getState().libraries.system, + ) + treeMap = treeResult.treeMap + + // Group trees by POU name for polling hook + for (const node of treeResult.trees) { + const pouName = node.compositeKey.split(':')[0] + if (!pouTrees[pouName]) pouTrees[pouName] = [] + pouTrees[pouName].push(node) + } + + for (const w of treeResult.warnings) { + logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) } - const entriesForTree = debugMapToEntries(debugMap) logActions.addLog({ id: crypto.randomUUID(), level: 'info', - message: `Debug map: ${debugMap.leaves.length} leaves across ${debugMap.arrays.length} arrays.`, + message: `Debug tree builder: Built ${treeResult.trees.length} trees (${treeResult.complexCount} complex).`, }) + } catch { + logActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: 'Debug tree builder encountered errors.', + }) + } - // Build the debug variable tree — the single enumeration walk. The - // composite-key → index map (used by the LD/FBD editors and the poller) - // is derived from this same tree, so every consumer resolves a - // variable's address identically. - let treeMap = new Map() - const pouTrees: Record = {} - try { - const treeResult = buildDebugVariableTreeMap( - project.data.pous, - instances, - entriesForTree, - project.data, - useOpenPLCStore.getState().libraries.system, - ) - treeMap = treeResult.treeMap - - // Group trees by POU name for polling hook - for (const node of treeResult.trees) { - const pouName = node.compositeKey.split(':')[0] - if (!pouTrees[pouName]) pouTrees[pouName] = [] - pouTrees[pouName].push(node) - } - - for (const w of treeResult.warnings) { - logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) - } - - logActions.addLog({ - id: crypto.randomUUID(), - level: 'info', - message: `Debug tree builder: Built ${treeResult.trees.length} trees (${treeResult.complexCount} complex).`, - }) - } catch { - logActions.addLog({ - id: crypto.randomUUID(), - level: 'warning', - message: 'Debug tree builder encountered errors.', - }) - } - - debugTreesRef.current = pouTrees - - // Derive the composite-key → packed-address map from the tree leaves. - const indexMap = deriveVariableIndexMap(treeMap, debugMap) - - // Build FB instance map - const fbDebugInstancesMap = buildFbInstanceMap(project.data.pous, instances) - - const fbTypesCount = fbDebugInstancesMap.size - const totalFbInstances = Array.from(fbDebugInstancesMap.values()).reduce((sum, list) => sum + list.length, 0) - if (fbTypesCount > 0) { - logActions.addLog({ - id: crypto.randomUUID(), - level: 'info', - message: `FB instance map: Found ${totalFbInstances} instances across ${fbTypesCount} FB types.`, - }) - } - - // Connect debugger via port - const connectResult = await debuggerPort.connect() - if (!connectResult.success) { - const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } - } + debugTreesRef.current = pouTrees - // Store debug artifacts in workspace - wsActions.setDebugVariableIndexes(indexMap) - wsActions.setDebugVariableTree(treeMap) - wsActions.setFbDebugInstances(fbDebugInstancesMap) + // Derive the composite-key → packed-address map from the tree leaves. + const indexMap = deriveVariableIndexMap(treeMap, debugMap) - // Set default selected instance for each FB type - fbDebugInstancesMap.forEach((instanceList: FbInstanceInfo[], fbTypeName: string) => { - if (instanceList.length > 0) { - wsActions.setFbSelectedInstance(fbTypeName, instanceList[0].key) - } - }) + // Build FB instance map + const fbDebugInstancesMap = buildFbInstanceMap(project.data.pous, instances) - // Set target IP for non-simulator connections - // The target's address, for the debugger's own display. Comes from the - // session the manager holds, not from a config the caller chose. - const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port - if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) - - // Record the active transport so useDebugPolling picks the right - // poll cadence + batch size. Set on EVERY start path (runtime - // targets also set it earlier in handleMd5Verification; this - // additionally covers the simulator path, which doesn't go - // through MD5 verification — without it the simulator stayed at - // the default 200ms instead of its intended 50ms). Must be set - // before `setDebuggerVisible(true)`, which is what triggers the - // polling effect. - // The medium is the manager's choice, mirrored in the store; read it rather - // than assuming one. `debugTransport` because this sizes the debug poll. - wsActions.setDebugConnectionType(useOpenPLCStore.getState().deviceConnection.debugTransport ?? 'simulator') - - wsActions.setDebuggerVisible(true) + const fbTypesCount = fbDebugInstancesMap.size + const totalFbInstances = Array.from(fbDebugInstancesMap.values()).reduce((sum, list) => sum + list.length, 0) + if (fbTypesCount > 0) { logActions.addLog({ id: crypto.randomUUID(), level: 'info', - message: `Debugger connected. Found ${indexMap.size} debug variables.`, + message: `FB instance map: Found ${totalFbInstances} instances across ${fbTypesCount} FB types.`, }) + } - return { success: true } - } catch (err: unknown) { - const error = `Debugger error: ${err instanceof Error ? err.message : String(err)}` + // Connect debugger via port + const connectResult = await debuggerPort.connect() + if (!connectResult.success) { + const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) return { success: false, error } } - }, - [debuggerPort, deviceDefinitions, projectData, projectMeta], - ) + + // Store debug artifacts in workspace + wsActions.setDebugVariableIndexes(indexMap) + wsActions.setDebugVariableTree(treeMap) + wsActions.setFbDebugInstances(fbDebugInstancesMap) + + // Set default selected instance for each FB type + fbDebugInstancesMap.forEach((instanceList: FbInstanceInfo[], fbTypeName: string) => { + if (instanceList.length > 0) { + wsActions.setFbSelectedInstance(fbTypeName, instanceList[0].key) + } + }) + + // Set target IP for non-simulator connections + // The target's address, for the debugger's own display. Comes from the + // session the manager holds, not from a config the caller chose. + const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port + if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) + + // Record the active transport so useDebugPolling picks the right + // poll cadence + batch size. Set on EVERY start path (runtime + // targets also set it earlier in handleMd5Verification; this + // additionally covers the simulator path, which doesn't go + // through MD5 verification — without it the simulator stayed at + // the default 200ms instead of its intended 50ms). Must be set + // before `setDebuggerVisible(true)`, which is what triggers the + // polling effect. + // The medium is the manager's choice, mirrored in the store; read it rather + // than assuming one. `debugTransport` because this sizes the debug poll. + wsActions.setDebugConnectionType(useOpenPLCStore.getState().deviceConnection.debugTransport ?? 'simulator') + + wsActions.setDebuggerVisible(true) + logActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `Debugger connected. Found ${indexMap.size} debug variables.`, + }) + + return { success: true } + } catch (err: unknown) { + const error = `Debugger error: ${err instanceof Error ? err.message : String(err)}` + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } + } + }, [debuggerPort, deviceDefinitions, projectData, projectMeta]) /** * End the debug session — and ONLY the debug session. diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 604401a1e..92ffed027 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -158,7 +158,12 @@ describe('createDeviceSlice', () => { it('has a disconnected serial connection', () => { const store = makeStore() - expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) }) }) @@ -169,28 +174,48 @@ describe('createDeviceSlice', () => { it('setDeviceConnectionStatus updates status and port', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') - expect(store.getState().deviceConnection).toEqual({ status: 'connecting', port: 'COM5', transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'connecting', + port: 'COM5', + transport: null, + debugTransport: null, + }) }) it('setDeviceConnectionStatus leaves the port unchanged when omitted', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') store.getState().deviceActions.setDeviceConnectionStatus('connected') - expect(store.getState().deviceConnection).toEqual({ status: 'connected', port: 'COM5', transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'connected', + port: 'COM5', + transport: null, + debugTransport: null, + }) }) it('setDeviceConnectionStatus can explicitly clear the port with null', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') store.getState().deviceActions.setDeviceConnectionStatus('error', null) - expect(store.getState().deviceConnection).toEqual({ status: 'error', port: null, transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'error', + port: null, + transport: null, + debugTransport: null, + }) }) it('clearDeviceConnection resets to disconnected/null', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') store.getState().deviceActions.clearDeviceConnection() - expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) }) }) @@ -346,7 +371,12 @@ describe('createDeviceSlice', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') store.getState().deviceActions.clearDeviceDefinitions() - expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) }) }) diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index ee6a2dba3..dbad28dc3 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -182,7 +182,7 @@ describe('Device slice types', () => { jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, - switchPosition: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, diff --git a/src/frontend/utils/__tests__/serial-port-label.test.ts b/src/frontend/utils/__tests__/serial-port-label.test.ts index 132b867af..0e9e5323a 100644 --- a/src/frontend/utils/__tests__/serial-port-label.test.ts +++ b/src/frontend/utils/__tests__/serial-port-label.test.ts @@ -38,9 +38,10 @@ describe('serialPortDisplay', () => { describe('descriptor precedence', () => { it('prefers the arduino-cli board name over the manufacturer', () => { // Both scans found something; the board name is the specific one. - expect( - serialPortDisplay({ address: 'COM1', boardName: 'Opta', manufacturer: 'Arduino' }), - ).toEqual({ label: 'COM1 (Opta)', title: 'COM1 (Opta)' }) + expect(serialPortDisplay({ address: 'COM1', boardName: 'Opta', manufacturer: 'Arduino' })).toEqual({ + label: 'COM1 (Opta)', + title: 'COM1 (Opta)', + }) }) it('falls back to the manufacturer when arduino-cli identified no board', () => { diff --git a/src/frontend/utils/vpp/__tests__/field-options.test.ts b/src/frontend/utils/vpp/__tests__/field-options.test.ts index 5ca4b9381..5e84ee136 100644 --- a/src/frontend/utils/vpp/__tests__/field-options.test.ts +++ b/src/frontend/utils/vpp/__tests__/field-options.test.ts @@ -11,16 +11,23 @@ describe('resolveFieldOptions', () => { it('resolves a dynamic optionsRef from board context', () => { expect( - resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: { serialPorts: ['Serial', 'Serial1'] } }), + resolveFieldOptions( + { optionsRef: 'board.serialPorts', options: ['Serial'] }, + { board: { serialPorts: ['Serial', 'Serial1'] } }, + ), ).toEqual(['Serial', 'Serial1']) }) it('falls back to static options when optionsRef resolves to undefined', () => { - expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: {} })).toEqual(['Serial']) + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: {} })).toEqual([ + 'Serial', + ]) }) it('falls back to static options when the board is absent', () => { - expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: undefined })).toEqual(['Serial']) + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: undefined })).toEqual( + ['Serial'], + ) }) it('falls back to static options when optionsRef resolves to an empty array', () => { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 4b31d9765..5197a2df5 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -46,6 +46,12 @@ import { join, resolve, sep } from 'path' import { platform } from 'process' import { MainIpcModule, MainIpcModuleConstructor } from '../../../backend/editor/contracts/types/modules/ipc/main' +import { + classifyDeviceLink, + type DeviceProbeOutcome, + PATIENT_BOARD_ID_PROBE, + QUICK_BOARD_ID_PROBE, +} from '../../../backend/editor/hardware/device-probe' import { type DeviceDebugCandidate, type DeviceLinkCandidate, @@ -56,12 +62,6 @@ import { buildDeviceModbusTransport, modbusTransportKind, } from '../../../backend/editor/hardware/device-transport-factory' -import { - classifyDeviceLink, - type DeviceProbeOutcome, - PATIENT_BOARD_ID_PROBE, - QUICK_BOARD_ID_PROBE, -} from '../../../backend/editor/hardware/device-probe' import { LibraryManagerModule } from '../../../backend/editor/library-manager' import { PackageManagerModule } from '../../../backend/editor/package-manager' import { logger } from '../../../backend/editor/services' @@ -1719,10 +1719,7 @@ class MainProcessBridge implements MainIpcModule { * client at a time, never answered. The user saw "Failed to stop PLC: Request * timeout" while a working connection sat idle. */ - handleDebuggerPlcControl = async ( - _event: IpcMainInvokeEvent, - action: 'run' | 'stop', - ): Promise => { + handleDebuggerPlcControl = async (_event: IpcMainInvokeEvent, action: 'run' | 'stop'): Promise => { this.traceDeviceLink(`run/stop: ${action} requested`) // Routed by the session's CONTROL channel, which is the whole point: the caller @@ -1852,7 +1849,6 @@ class MainProcessBridge implements MainIpcModule { } } - /** * Read the run/stop state over an already-open client and push it to the * renderer. Throttled, because its two callers tick at very different rates: @@ -1883,8 +1879,6 @@ class MainProcessBridge implements MainIpcModule { }) } - - /** * Start a debug session against a target. * @@ -2259,7 +2253,6 @@ class MainProcessBridge implements MainIpcModule { return { status: 'no-response', error: tried || 'No connection could be established.' } } - /** Close the held link (user pressed Disconnect). */ handleDeviceDisconnect = async (): Promise<{ success: boolean }> => { this.deviceSession.close() diff --git a/src/middleware/adapters/editor/debugger-adapter.ts b/src/middleware/adapters/editor/debugger-adapter.ts index 864f63191..b4e76c776 100644 --- a/src/middleware/adapters/editor/debugger-adapter.ts +++ b/src/middleware/adapters/editor/debugger-adapter.ts @@ -13,12 +13,7 @@ import type { PlcControlResult } from '../../../backend/shared/debug/types' import { getErrorMessage } from '../../../frontend/utils/get-error-message' import type { DebuggerPort } from '../../shared/ports/debugger-port' -import type { - DebugSetResult, - DebugVariableResult, - Md5VerifyResult, - Unsubscribe, -} from '../../shared/ports/types' +import type { DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from '../../shared/ports/types' export function createEditorDebuggerAdapter(): DebuggerPort { let connected = false diff --git a/src/middleware/adapters/editor/device-adapter.ts b/src/middleware/adapters/editor/device-adapter.ts index 000f0d69f..85185b214 100644 --- a/src/middleware/adapters/editor/device-adapter.ts +++ b/src/middleware/adapters/editor/device-adapter.ts @@ -14,11 +14,7 @@ * util:get-preview-image (invoke) */ -import type { - DeviceConnectionStatusPayload, - DeviceConnectResult, - DevicePort, -} from '../../shared/ports/device-port' +import type { DeviceConnectionStatusPayload, DeviceConnectResult, DevicePort } from '../../shared/ports/device-port' import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from '../../shared/ports/types' export function createEditorDeviceAdapter(): DevicePort { @@ -75,9 +71,7 @@ export function createEditorDeviceAdapter(): DevicePort { return window.bridge.onDeviceConnectionStatus(callback) }, - onPlcState( - callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, - ): () => void { + onPlcState(callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void): () => void { return window.bridge.onDevicePlcState(callback) }, } diff --git a/src/middleware/shared/ports/device-port.ts b/src/middleware/shared/ports/device-port.ts index 98538d72d..8539f78d5 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -180,7 +180,5 @@ export interface DevicePort { * 0/1/2 (STOPPED/RUNNING/ERROR); `switchPosition` is 0/1 (STOP/RUN) and is * absent on firmware predating the run/stop state machine. */ - onPlcState?( - callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, - ): () => void + onPlcState?(callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void): () => void } From 0aa0c8af0ec7bdffbfe2c3f4856a038fd509b8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 13:27:48 +0200 Subject: [PATCH 21/79] fix(editor): align the debugger's baud with the firmware, and sweep on a miss Two defects, one symptom: "No Firmware Detected" on a board that is running perfectly well. FIRST, a real misalignment. `DEBUG_BAUD` was read from the `serial` screen section alone, falling back to 115200. A VPP published today has no `serial` section -- it configures the baud on the RTU section -- so every project built against a published package compiled a firmware listening at 115200 while the editor dialled the RTU's rate. The port opened, nothing decoded, and the editor concluded there was no firmware. `resolveDebugBaud` states the rule the firmware actually follows: an explicit `serial` section wins; otherwise the RTU's baud, but ONLY when the RTU is on the default port, because there the firmware brings that one port up at MBSERIAL_BAUD and the debugger shares it (MBSERIAL_SHARES_DEBUG_SERIAL); otherwise the firmware default. With the RTU on a SECOND UART the debugger keeps the default port to itself and nothing in the project states its speed -- which is what the second half of this commit is for. SECOND, defence in depth. A wrong baud is the one misconfiguration that presents as healthy silence, and the advice it produced -- reflash the device -- is exactly the wrong move on a device in the field. Connect now tries the configured rate first and then sweeps FALLBACK_BAUD_RATES, so a board whose rate nobody remembers is reachable instead of looking dead. The sweep is deliberately not free-form: - Guesses are appended AFTER every endpoint the project declared. A configured Modbus TCP address is a better next try than a rate nobody asked for. - Guesses carry `speculative`, and verification gives them SPECULATIVE_BOARD_ID_PROBE (2 attempts) instead of the patient budget. Two rather than one because opening the port asserts DTR and resets AVR/ESP8266 boards, so the first read can land mid-boot and reject a correct rate. - The patient budget stays with the last DECLARED endpoint (`patient`), not the last candidate overall. Without that the sweep would quietly take ~32s of patience away from the configured endpoint -- the one case that needs it, a board still booting right after a flash. - Each guess names its rate in the trace, so the console says which rate is being tried rather than listing the same port five times. - The debug channel of an already-established session opts out (`probeBaudRates: false`): there the rate is settled, and reopening the port at other rates would be wrong, not merely wasteful. Cost, stated plainly: a full sweep is four extra port opens, each resetting the board. It runs only after everything configured has already failed. Co-Authored-By: Claude Opus 5 --- .../hardware/__tests__/device-probe.test.ts | 121 ++++++++++++++++++ src/backend/editor/hardware/device-probe.ts | 57 +++++++++ .../editor/hardware/device-session-manager.ts | 13 ++ .../__tests__/generate-defines.test.ts | 34 +++++ .../compile/__tests__/modbus-defines.test.ts | 54 +++++++- .../shared/compile/steps/generate-defines.ts | 9 +- .../shared/compile/steps/modbus-defines.ts | 38 ++++++ src/main/modules/ipc/main.ts | 69 ++++++++-- 8 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/device-probe.test.ts diff --git a/src/backend/editor/hardware/__tests__/device-probe.test.ts b/src/backend/editor/hardware/__tests__/device-probe.test.ts new file mode 100644 index 000000000..192040d79 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-probe.test.ts @@ -0,0 +1,121 @@ +import type { DebugBoardIdResult } from '@root/backend/shared/debug/types' + +import { classifyDeviceLink, FALLBACK_BAUD_RATES, planBaudAttempts, readBoardIdWithRetries } from '../device-probe' + +/** + * A channel that answers the board-id read (FC 0x48) according to a script, so + * the classification can be tested without a board. + */ +function fakeChannel(script: DebugBoardIdResult[]) { + const calls: number[] = [] + let index = 0 + return { + calls, + getBoardId: (): Promise => { + calls.push(index) + const answer = script[Math.min(index, script.length - 1)] + index += 1 + return Promise.resolve(answer) + }, + } +} + +const ANSWERED: DebugBoardIdResult = { success: true, boardId: Uint8Array.from([1, 2, 3, 4]) } +/** Opened, but nothing spoke the debug protocol — a blank board, or a wrong baud. */ +const SILENT: DebugBoardIdResult = { success: false } +/** Answered the frame but reported no unique id (a core without ArduinoUniqueID). */ +const EMPTY_ID: DebugBoardIdResult = { success: true, boardId: Uint8Array.from([]) } + +describe('planBaudAttempts', () => { + it('leads with the configured rate, then sweeps the rest', () => { + const plan = planBaudAttempts(9600) + + expect(plan[0]).toEqual({ baudRate: 9600, speculative: false }) + expect(plan.slice(1).every((attempt) => attempt.speculative)).toBe(true) + // The configured rate is never repeated as a guess. + expect(plan.filter((attempt) => attempt.baudRate === 9600)).toHaveLength(1) + expect(plan).toHaveLength(FALLBACK_BAUD_RATES.length) + }) + + it('covers every fallback rate exactly once', () => { + const rates = planBaudAttempts(115200).map((attempt) => attempt.baudRate) + + expect(new Set(rates).size).toBe(rates.length) + for (const rate of FALLBACK_BAUD_RATES) expect(rates).toContain(rate) + }) + + it('does not sweep an endpoint with no baud rate (TCP / WebSocket)', () => { + expect(planBaudAttempts(undefined)).toEqual([{ baudRate: undefined, speculative: false }]) + }) + + it('does not sweep when the caller opts out', () => { + // The debug channel of an established session: the rate is already settled, + // so re-opening the port at other rates would be wrong, not merely wasteful. + expect(planBaudAttempts(9600, { sweep: false })).toEqual([{ baudRate: 9600, speculative: false }]) + }) + + it('keeps a rate that is not in the fallback list as the leading attempt', () => { + const plan = planBaudAttempts(4800) + + expect(plan[0]).toEqual({ baudRate: 4800, speculative: false }) + expect(plan).toHaveLength(FALLBACK_BAUD_RATES.length + 1) + }) +}) + +describe('readBoardIdWithRetries', () => { + it('stops at the first answer', async () => { + const channel = fakeChannel([ANSWERED]) + const result = await readBoardIdWithRetries(channel, { attempts: 6, backoffMs: 0 }) + + expect(result.success).toBe(true) + expect(channel.calls).toHaveLength(1) + }) + + it('retries a silent port up to the budget — a board can still be booting', async () => { + const channel = fakeChannel([SILENT, SILENT, ANSWERED]) + const result = await readBoardIdWithRetries(channel, { attempts: 3, backoffMs: 0 }) + + expect(result.success).toBe(true) + expect(channel.calls).toHaveLength(3) + }) + + it('spends no more than the budget allows', async () => { + const channel = fakeChannel([SILENT]) + const result = await readBoardIdWithRetries(channel, { attempts: 2, backoffMs: 0 }) + + expect(result.success).toBe(false) + expect(channel.calls).toHaveLength(2) + }) +}) + +describe('classifyDeviceLink', () => { + it('keeps a channel a firmware answered on', async () => { + const result = await classifyDeviceLink(fakeChannel([ANSWERED]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'connected-with-firmware' }) + }) + + // This is what a WRONG BAUD looks like from here: the port opened, so the + // transport is fine, and nothing decoded. Reporting it as `no-firmware` is what + // lets the caller fall through to the next rate instead of keeping a dead link. + it('reports no-firmware when the channel opens but nothing answers', async () => { + const result = await classifyDeviceLink(fakeChannel([SILENT]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'no-firmware' }) + }) + + it('reports no-firmware when the frame came back with an empty id', async () => { + const result = await classifyDeviceLink(fakeChannel([EMPTY_ID]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'no-firmware' }) + }) + + it('never throws — a transport that blows up resolves to an error status', async () => { + const result = await classifyDeviceLink( + { getBoardId: () => Promise.reject(new Error('port disappeared')) }, + { boardIdProbe: { attempts: 1, backoffMs: 0 } }, + ) + + expect(result).toEqual({ status: 'error', error: 'port disappeared' }) + }) +}) diff --git a/src/backend/editor/hardware/device-probe.ts b/src/backend/editor/hardware/device-probe.ts index 295658f5d..6ab3006b6 100644 --- a/src/backend/editor/hardware/device-probe.ts +++ b/src/backend/editor/hardware/device-probe.ts @@ -40,6 +40,63 @@ export interface ProbeBudget { */ export const PATIENT_BOARD_ID_PROBE: ProbeBudget = { attempts: 6, backoffMs: 500 } export const QUICK_BOARD_ID_PROBE: ProbeBudget = { attempts: 2, backoffMs: 300 } +/** + * For a SPECULATIVE candidate — an alternative baud rate nobody configured. + * + * Two attempts rather than one, and not out of optimism: opening the port asserts + * DTR, which resets an AVR or ESP8266, so the first read after the open can land + * while the board is still booting. One attempt would reject a correct rate for a + * reason that has nothing to do with the rate. Two is the floor that makes the + * sweep trustworthy; more would multiply across every rate tried. + */ +export const SPECULATIVE_BOARD_ID_PROBE: ProbeBudget = { attempts: 2, backoffMs: 400 } + +/** + * Baud rates tried, in this order, when the configured one does not answer. + * + * A board whose baud nobody remembers is otherwise unreachable, and it fails in + * the most misleading way available: the port opens (so it is not "no response"), + * nothing decodes (so it reads as "no firmware"), and the user is told to reflash + * a device that is running perfectly well. In the field that reflash is the + * expensive part — it is why this sweep exists. + * + * Ordered by how often they occur in practice, not numerically. Deliberately + * short: every wrong rate costs a port open, and on AVR/ESP8266 opening the port + * asserts DTR and RESETS the board, so a wide sweep is not free — it restarts the + * user's program once per guess. + */ +export const FALLBACK_BAUD_RATES = [115200, 9600, 57600, 19200, 38400] as const + +/** One baud rate to try, and whether trying it is a guess. */ +export interface BaudAttempt { + baudRate: number | undefined + /** True for a rate nobody configured — verification keeps these cheap. */ + speculative: boolean +} + +/** + * The order to try baud rates in for one serial endpoint: the configured rate + * first, then every fallback that isn't it. + * + * The configured rate leads because it is nearly always right, and a correct + * first try costs one port open. The guesses follow in `FALLBACK_BAUD_RATES` + * order. + * + * Returns a single non-speculative attempt when there is no rate to sweep — a + * TCP or WebSocket endpoint (`undefined`), or a caller that opted out. + */ +export function planBaudAttempts(declaredBaud: number | undefined, options: { sweep?: boolean } = {}): BaudAttempt[] { + const declared: BaudAttempt = { baudRate: declaredBaud, speculative: false } + if (options.sweep === false || typeof declaredBaud !== 'number') return [declared] + + return [ + declared, + ...FALLBACK_BAUD_RATES.filter((baud) => baud !== declaredBaud).map((baud) => ({ + baudRate: baud, + speculative: true, + })), + ] +} /** * Connect with a bounded retry/backoff loop. A device flashed over arduino-cli diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 54b0d7133..3780d7700 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -68,6 +68,19 @@ export interface DeviceLinkCandidate { descriptor: string /** Build an unconnected client for this candidate. */ create: () => DeviceModbusTransport + /** + * A guess rather than something the project declared (an alternative baud + * rate). Verification spends a short budget on these — there may be several, + * and being wrong about one must not delay the next. + */ + speculative?: boolean + /** + * Worth waiting for. Set on the last candidate the project actually declared, + * so it keeps the patient probe budget even when speculative candidates queue + * up behind it — a board that was just flashed is still booting, and that wait + * belongs to the configured endpoint, not to a guess. + */ + patient?: boolean } /** Live link state, as pushed to the renderer. */ diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index a7b7b750c..a9196d313 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -200,6 +200,40 @@ describe('generateDefinesContent — Debugger block (always-on debug)', () => { expect(out).toContain('#define DEBUG_BAUD 115200') }) + // A PUBLISHED VPP has no `serial` section — only the legacy RTU fields. The + // debugger and the RTU then share one port, so ONE rate must come out of this + // file. Emitting 115200 while MBSERIAL_BAUD said 9600 built a firmware the + // editor could not talk to, and the user was told "No Firmware Detected" about + // a board that was running fine. + it('aligns DEBUG_BAUD with MBSERIAL_BAUD for a published VPP (no `serial` section)', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600', rtu_slave_id: 1 }, + }, + }) + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + expect(out).toContain('#define DEBUG_BAUD 9600') + }) + + it('keeps DEBUG_BAUD at the firmware default when the RTU has its own second port', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '9600', rtu_slave_id: 1 }, + }, + }) + // Two distinct ports, two distinct rates — and the debugger keeps the default. + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_ON_SECONDARY') + expect(out).toContain('#define DEBUG_BAUD 115200') + }) + it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'simulator' }) expect(out).not.toContain('DEBUGGER_ENABLED') diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index 94758541b..90aa9e3c4 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -1,4 +1,56 @@ -import { generateModbusDefines } from '../steps/modbus-defines' +import { DEFAULT_DEBUG_BAUD, generateModbusDefines, resolveDebugBaud } from '../steps/modbus-defines' + +/** + * The baud the always-on debugger answers on. It has to agree with the rate the + * editor dials, and the two are derived in different places — so these pin the + * derivation against the shapes real projects actually persist. + */ +describe('resolveDebugBaud', () => { + it('prefers an explicit `serial` section when a package declares one', () => { + expect( + resolveDebugBaud({ serial: { baud_rate: '57600' }, modbus_rtu: { enabled: true, rtu_baud_rate: '9600' } }), + ).toBe('57600') + }) + + // The regression this function exists for: a PUBLISHED VPP has no `serial` + // section, so the RTU's baud is the only statement of the default port's speed. + // Reading 115200 instead compiled a firmware listening at one rate while the + // editor dialled another, and the board answered nothing at all. + it('takes the RTU baud when the RTU shares the default port (published VPP shape)', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600' } })).toBe( + '9600', + ) + }) + + it('takes the RTU baud when the RTU names no port at all (defaults to the default one)', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_baud_rate: '19200' } })).toBe('19200') + }) + + it('honours a board whose default serial is not called `Serial`', () => { + expect( + resolveDebugBaud( + { modbus_rtu: { enabled: true, rtu_interface: 'SerialUSB', rtu_baud_rate: '38400' } }, + 'SerialUSB', + ), + ).toBe('38400') + }) + + it('ignores the RTU baud when the RTU is on a SECOND port', () => { + // There the debugger keeps the default port to itself and nothing in the + // project states its speed, so the firmware default is the only answer. + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '9600' } })).toBe( + DEFAULT_DEBUG_BAUD, + ) + }) + + it('ignores the RTU baud when the RTU is disabled', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } })).toBe(DEFAULT_DEBUG_BAUD) + }) + + it('falls back for an empty project', () => { + expect(resolveDebugBaud({})).toBe(DEFAULT_DEBUG_BAUD) + }) +}) describe('generateModbusDefines', () => { it('returns an empty string when neither RTU nor TCP is enabled', () => { diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index 61445cc95..dc62c0899 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -18,7 +18,7 @@ */ import type { DevicePin } from '../../types/PLC/devices' -import { generateModbusDefines, type VppModbusScreenState } from './modbus-defines' +import { generateModbusDefines, resolveDebugBaud, type VppModbusScreenState } from './modbus-defines' export type { VppModbusScreenState } from './modbus-defines' @@ -186,7 +186,12 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += '//Debugger\n' DEFINES_CONTENT += '#define DEBUGGER_ENABLED\n' DEFINES_CONTENT += `#define DEBUG_IFACE ${defaultSerial ?? 'Serial'}\n` - DEFINES_CONTENT += `#define DEBUG_BAUD ${vppModbusState?.serial?.baud_rate ?? '115200'}\n` + // Not `serial.baud_rate ?? 115200`: a package published without a `serial` + // section still configures a baud — on the RTU section — and when the RTU + // shares the default port that IS this port's speed. Ignoring it compiled a + // firmware listening at 115200 while the editor dialled the RTU's baud, and + // the board answered nothing ("No Firmware Detected" on a healthy board). + DEFINES_CONTENT += `#define DEBUG_BAUD ${resolveDebugBaud(vppModbusState ?? {}, defaultSerial)}\n` DEFINES_CONTENT += `\n\n` } diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 260d7c00e..d2cd340b3 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -76,6 +76,44 @@ export interface VppModbusScreenState { } } +/** Baud the always-on debugger falls back to when nothing else says otherwise. */ +export const DEFAULT_DEBUG_BAUD = '115200' + +/** + * Baud rate the DEFAULT serial port comes up at — the one the always-on + * debugger answers on, and therefore the one the editor must dial to reach it. + * + * Three sources, in order: + * + * 1. The `serial` section, when a package declares one. It exists precisely to + * configure this port independently of Modbus. + * 2. Otherwise, the RTU's baud — but ONLY when the RTU is on the default port, + * because there the firmware brings that one port up at `MBSERIAL_BAUD` and + * the debugger shares it (`MBSERIAL_SHARES_DEBUG_SERIAL`). Honouring it is + * what keeps firmware and editor on the same wire speed for every package + * published without a `serial` section. + * 3. Otherwise `115200`: either no RTU at all, or an RTU on a SECOND UART while + * the debugger keeps the default port to itself. Nothing in the project + * states that port's speed, so the firmware's default is the only answer — + * which is why the connect flow probes alternative bauds rather than + * trusting this one blindly. + */ +export function resolveDebugBaud(state: VppModbusScreenState, defaultSerial: string = 'Serial'): string { + const declared = state.serial?.baud_rate + if (declared) return declared + + const rtu = state.modbus_rtu + if (rtu?.enabled === true) { + const iface = rtu.serial_port ?? rtu.rtu_interface ?? defaultSerial + if (iface === defaultSerial) { + const shared = rtu.baud_rate ?? rtu.rtu_baud_rate + if (shared) return shared + } + } + + return DEFAULT_DEBUG_BAUD +} + /** * `aa:bb:cc:dd:ee:ff` → `0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff` so it can * land verbatim in `byte mac[] = { MBTCP_MAC };`. Accepts the canonical diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 5197a2df5..dc314589c 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -50,7 +50,9 @@ import { classifyDeviceLink, type DeviceProbeOutcome, PATIENT_BOARD_ID_PROBE, + planBaudAttempts, QUICK_BOARD_ID_PROBE, + SPECULATIVE_BOARD_ID_PROBE, } from '../../../backend/editor/hardware/device-probe' import { type DeviceDebugCandidate, @@ -1956,15 +1958,22 @@ class MainProcessBridge implements MainIpcModule { * The only transport-specific step left in the flow; a config that names a * transport this build cannot speak is dropped rather than half-built. */ - private toDeviceLinkCandidates(configs: DebugConnectionConfig[]): DeviceLinkCandidate[] { - const candidates: DeviceLinkCandidate[] = [] - for (const config of configs) { + private toDeviceLinkCandidates( + configs: DebugConnectionConfig[], + opts: { probeBaudRates?: boolean } = {}, + ): DeviceLinkCandidate[] { + const declared: DeviceLinkCandidate[] = [] + // Baud guesses go AFTER everything the project declared: a configured Modbus + // TCP address is a better next try than a rate nobody asked for. + const speculative: DeviceLinkCandidate[] = [] + + const build = (config: DebugConnectionConfig, baudRate: number | undefined, isGuess: boolean): void => { const kind = modbusTransportKind(config.connectionType) - if (kind === null) continue + if (kind === null) return const params = { connectionType: config.connectionType, port: config.connectionParams.port, - baudRate: config.connectionParams.baudRate, + baudRate, slaveId: config.connectionParams.slaveId, host: config.connectionParams.ipAddress, } @@ -1973,10 +1982,14 @@ class MainProcessBridge implements MainIpcModule { const options = kind === 'simulator' ? { virtualSerialPort: new VirtualSerialPort(this.simulatorModule) } : {} // Probe the params now so a malformed config fails resolution rather than // becoming a candidate that always throws on `create()`. - if ('error' in buildDeviceModbusTransport(params, options)) continue - candidates.push({ + if ('error' in buildDeviceModbusTransport(params, options)) return + const base = describeDebugEndpoint(config) + ;(isGuess ? speculative : declared).push({ transport: kind, - descriptor: describeDebugEndpoint(config), + // Name the rate on a guess: the trace and the failure dialog list what was + // tried, and "COM5" five times over tells the user nothing. + descriptor: isGuess ? `${base} @ ${baudRate} baud` : base, + speculative: isGuess, create: () => { const built = buildDeviceModbusTransport(params, options) if ('error' in built) throw new Error(built.error) @@ -1984,7 +1997,27 @@ class MainProcessBridge implements MainIpcModule { }, }) } - return candidates + + for (const config of configs) { + // A wrong baud is the one misconfiguration that looks like healthy silence: + // the port opens, so it is not "no response", and nothing decodes, so it + // reads as "no firmware" — and the user gets told to reflash a board that is + // running fine. Sweeping the rates OpenPLC is ever built with turns that dead + // end into a connection. Serial only; a TCP address is either right or not. + for (const attempt of planBaudAttempts(config.connectionParams.baudRate, { sweep: opts.probeBaudRates })) { + build(config, attempt.baudRate, attempt.speculative) + } + } + + // The patient budget belongs to the last DECLARED endpoint, not to the last + // candidate overall. Without this the baud sweep would silently take that + // patience away from the configured endpoint and hand it to a guess — and a + // board that was just flashed, still booting on the right rate, would be ruled + // out in ~10s instead of the ~32s it sometimes needs. + const lastDeclared = declared[declared.length - 1] + if (lastDeclared) lastDeclared.patient = true + + return [...declared, ...speculative] } /** Consume the classification the last verified candidate produced. */ @@ -2041,7 +2074,9 @@ class MainProcessBridge implements MainIpcModule { create: () => new WebSocketDebugTransport({ host, port: 8443, token, rejectUnauthorized: false }), } } - const [candidate] = this.toDeviceLinkCandidates([config]) + // One config in, one candidate out: this builds the DEBUG channel for a + // session that already exists, so the rate is settled and guessing is wrong. + const [candidate] = this.toDeviceLinkCandidates([config], { probeBaudRates: false }) if (!candidate) return null return { transport: candidate.transport, @@ -2088,10 +2123,20 @@ class MainProcessBridge implements MainIpcModule { // the only way in, but not while alternatives are waiting: a Modbus TCP // address that no longer answers should not delay the cable that would have // worked. (Measured on a real board: 32.5s to rule out one endpoint.) - const boardIdProbe = context.isLastCandidate ? PATIENT_BOARD_ID_PROBE : QUICK_BOARD_ID_PROBE + // + // A speculative candidate never gets that patience, whether or not it happens + // to be last: it is a baud rate NOBODY configured, and there are several of + // them. Spending the patient budget on the final guess would put ~32s at the + // end of a sweep whose whole point is to finish quickly. + const isPatient = !candidate.speculative && (candidate.patient === true || context.isLastCandidate) + const boardIdProbe = candidate.speculative + ? SPECULATIVE_BOARD_ID_PROBE + : isPatient + ? PATIENT_BOARD_ID_PROBE + : QUICK_BOARD_ID_PROBE this.traceDeviceLink( ` ${candidate.descriptor}: verifying with up to ${boardIdProbe.attempts} id read(s)` + - `${context.isLastCandidate ? ' (last candidate, being patient)' : ''}`, + `${candidate.speculative ? ' (baud guess)' : isPatient ? ' (last configured endpoint, being patient)' : ''}`, ) const result = await classifyDeviceLink(client, { boardIdProbe }) this.deviceLinkProbe = result From ac16ea7d5713d09f6754c1ed9626cd8f1a49254e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 13:47:09 +0200 Subject: [PATCH 22/79] fix(editor): keep the baud out of a candidate's descriptor, and dial the screen's rate when Modbus is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the first field test exposed, both mine. THE SWEEP NEVER RAN. `descriptor` is an IDENTIFIER: it is matched against the OS serial-port list and against the port an upload asks to borrow. Writing "COM5 @ 115200 baud" into it meant every swept candidate matched no port and was dismissed as absent in 1ms: COM5 @ 115200 baud: serial port is not enumerated, skipping open: rtu COM5 @ 115200 baud rejected in 2ms — not available The rate now travels beside the endpoint as `baudRate`, and `describeLinkCandidate` composes the caption where a caption is wanted (trace, attempt list). The same slip would have broken the upload handoff, which compares the held descriptor against the port arduino-cli wants. The trace also named the rate on guesses only, so the log showed four rates being tried and never said which one the CONFIGURED attempt used — the reader could not tell that the 32s patient probe had already spent itself on 9600. Every serial candidate now reports its rate. DEBUG_BAUD IGNORED A DISABLED RTU. The editor dials `screens.modbus_rtu.rtu_baud_rate` whether or not the RTU is enabled: a debug spec's `params` are read independently of its `enabledWhen`. `resolveDebugBaud` required `enabled === true`, so a project with Modbus off and 9600 on the screen compiled a firmware at 115200 while the editor dialled 9600 — the exact shape of the reported failure. The rate is now taken whenever it exists, except when an ENABLED RTU owns a second UART: only there does it genuinely describe a different port. Regression tests, both of which the previous commit passed: - two candidates on one port, differing only in baud, are both opened rather than skipped as missing ports; - a failed open reports each attempt's rate; - DEBUG_BAUD follows the screen rate with the RTU disabled, and only falls back to 115200 for an enabled RTU on a second UART. Co-Authored-By: Claude Opus 5 --- .../__tests__/device-session-manager.test.ts | 54 +++++++++++++++ .../editor/hardware/device-session-manager.ts | 68 ++++++++++++++----- .../__tests__/generate-defines.test.ts | 17 +++++ .../compile/__tests__/modbus-defines.test.ts | 20 +++++- .../shared/compile/steps/modbus-defines.ts | 47 +++++++------ src/main/modules/ipc/main.ts | 13 ++-- 6 files changed, 174 insertions(+), 45 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts index 7b0801ffd..6b0b89a81 100644 --- a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -81,6 +81,7 @@ function candidate( descriptor: string, queue: FakeClient[], registry: FakeClient[], + extra: Partial = {}, ): DeviceLinkCandidate { return { transport, @@ -90,6 +91,7 @@ function candidate( registry.push(client) return asTransport(client) }, + ...extra, } } @@ -116,6 +118,58 @@ describe('DeviceSessionManager', () => { h.manager.close() }) + // The baud sweep sends several candidates down the SAME port, differing only + // by `baudRate`. `descriptor` is matched against the OS port list, so it has + // to stay the bare port name: when the rate was folded into it ("COM5 @ 9600 + // baud") every swept candidate matched no port and was skipped in 1ms — the + // sweep silently did nothing at all. + it('tries every baud rate on one port instead of skipping them as absent ports', async () => { + const h = harness() + const wrongBaud = new FakeClient() + const rightBaud = new FakeClient() + // Only the real port name is enumerated, exactly as the OS reports it. + h.ports.clear() + h.ports.add('COM5') + // First candidate opens but answers nothing (what a wrong baud looks like). + let verified = 0 + const h2 = harness({ + serialPortPresent: async (port) => h.ports.has(port), + verify: async () => { + verified += 1 + return verified > 1 + }, + }) + + const result = await h2.manager.open([ + candidate('rtu', 'COM5', [wrongBaud], h2.clients, { baudRate: 9600, patient: true }), + candidate('rtu', 'COM5', [rightBaud], h2.clients, { baudRate: 115200, speculative: true }), + ]) + + expect(result.ok).toBe(true) + // Both were actually opened — the second was not dismissed as a missing port. + expect(wrongBaud.connectCount).toBe(1) + expect(rightBaud.connectCount).toBe(1) + if (result.ok) expect(result.descriptor).toBe('COM5') + h2.manager.close() + }) + + it('names the baud rate when reporting what it tried', async () => { + const h = harness({ verify: async () => false }) + h.ports.clear() + h.ports.add('COM5') + + const result = await h.manager.open([ + candidate('rtu', 'COM5', [], h.clients, { baudRate: 9600 }), + candidate('rtu', 'COM5', [], h.clients, { baudRate: 115200, speculative: true }), + ]) + + expect(result.ok).toBe(false) + if (!result.ok) { + // Two entries for one port are only meaningful if each says its rate. + expect(result.attempts.map((attempt) => attempt.baudRate)).toEqual([9600, 115200]) + } + }) + it('falls back to serial when Modbus TCP cannot connect', async () => { const h = harness() const tcp = new FakeClient({ connectFails: true }) diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 3780d7700..43394e398 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -64,8 +64,21 @@ export interface DeviceDebugCandidate { /** One way to reach the device, ready to be tried. */ export interface DeviceLinkCandidate { transport: DeviceLinkTransport - /** What the user calls this endpoint: "/dev/cu.usbmodem11101", "192.168.0.50". */ + /** + * What the user calls this endpoint: "/dev/cu.usbmodem11101", "192.168.0.50". + * + * This is an IDENTIFIER, not a caption: it is matched against the OS port list + * (`serialPortPresent`) and against the port an upload asks to borrow. Anything + * decorative belongs in `baudRate` or the trace, never here — a descriptor that + * read "COM5 @ 9600 baud" matched no port and no upload. + */ descriptor: string + /** + * Wire speed for a serial candidate, carried so the trace can name it. Two + * candidates on one port differ only by this, and a log that repeated the port + * five times said nothing about what was actually tried. + */ + baudRate?: number /** Build an unconnected client for this candidate. */ create: () => DeviceModbusTransport /** @@ -118,11 +131,27 @@ export interface DeviceLinkOpenSuccess { export interface DeviceLinkOpenFailure { ok: false /** Every candidate that was tried, with why it did not work. */ - attempts: Array<{ transport: DeviceLinkTransport; descriptor: string; error: string }> + attempts: Array<{ transport: DeviceLinkTransport; descriptor: string; baudRate?: number; error: string }> } export type DeviceLinkOpenResult = DeviceLinkOpenSuccess | DeviceLinkOpenFailure +/** + * How a candidate reads in the trace and in a failure message: the endpoint plus + * its wire speed, when it has one. + * + * Derived here rather than baked into `descriptor`, because that field is matched + * against OS port names and upload requests — see the note on it. + */ +export function describeLinkCandidate(candidate: { + transport: DeviceLinkTransport + descriptor: string + baudRate?: number +}): string { + const speed = candidate.baudRate === undefined ? '' : ` @ ${candidate.baudRate} baud` + return `${candidate.transport} ${candidate.descriptor}${speed}` +} + export interface DeviceLinkHooks { /** * Is this freshly opened client really a device we can work with? Decides @@ -331,11 +360,7 @@ export class DeviceSessionManager { this.candidates = candidates const attempts: DeviceLinkOpenFailure['attempts'] = [] - this.trace( - `open: ${candidates.length} candidate(s) in order: ${candidates - .map((candidate) => `${candidate.transport} ${candidate.descriptor}`) - .join(', ')}`, - ) + this.trace(`open: ${candidates.length} candidate(s) in order: ${candidates.map(describeLinkCandidate).join(', ')}`) for (const [index, candidate] of candidates.entries()) { this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) @@ -345,8 +370,8 @@ export class DeviceSessionManager { const elapsed = Date.now() - startedAt this.trace( outcome.ok - ? `open: ${candidate.transport} ${candidate.descriptor} ACCEPTED in ${elapsed}ms` - : `open: ${candidate.transport} ${candidate.descriptor} rejected in ${elapsed}ms — ${outcome.error}`, + ? `open: ${describeLinkCandidate(candidate)} ACCEPTED in ${elapsed}ms` + : `open: ${describeLinkCandidate(candidate)} rejected in ${elapsed}ms — ${outcome.error}`, ) if (outcome.ok) { this.client = outcome.client @@ -362,7 +387,12 @@ export class DeviceSessionManager { }) return { ok: true, transport: candidate.transport, descriptor: candidate.descriptor, client: outcome.client } } - attempts.push({ transport: candidate.transport, descriptor: candidate.descriptor, error: outcome.error }) + attempts.push({ + transport: candidate.transport, + descriptor: candidate.descriptor, + baudRate: candidate.baudRate, + error: outcome.error, + }) } this.candidates = [] @@ -406,7 +436,7 @@ export class DeviceSessionManager { // A serial candidate whose port is not even enumerated cannot be opened: // say so instead of waiting out a connect timeout. if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { - this.trace(` ${candidate.descriptor}: serial port is not enumerated, skipping`) + this.trace(` ${describeLinkCandidate(candidate)}: serial port is not enumerated, skipping`) return { ok: false, error: `${candidate.descriptor} is not available` } } @@ -420,10 +450,12 @@ export class DeviceSessionManager { const connectStartedAt = Date.now() try { await client.connect() - this.trace(` ${candidate.descriptor}: transport opened in ${Date.now() - connectStartedAt}ms`) + this.trace(` ${describeLinkCandidate(candidate)}: transport opened in ${Date.now() - connectStartedAt}ms`) } catch (error) { client.disconnect() - this.trace(` ${candidate.descriptor}: transport would not open after ${Date.now() - connectStartedAt}ms`) + this.trace( + ` ${describeLinkCandidate(candidate)}: transport would not open after ${Date.now() - connectStartedAt}ms`, + ) return { ok: false, error: describeError(error) } } @@ -433,17 +465,19 @@ export class DeviceSessionManager { const verifyStartedAt = Date.now() try { if (await this.hooks.verify(client, candidate, context)) { - this.trace(` ${candidate.descriptor}: answered the debug protocol in ${Date.now() - verifyStartedAt}ms`) + this.trace( + ` ${describeLinkCandidate(candidate)}: answered the debug protocol in ${Date.now() - verifyStartedAt}ms`, + ) return { ok: true, client } } client.disconnect() this.trace( - ` ${candidate.descriptor}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, + ` ${describeLinkCandidate(candidate)}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, ) return { ok: false, error: 'No OpenPLC firmware answered' } } catch (error) { client.disconnect() - this.trace(` ${candidate.descriptor}: verification threw after ${Date.now() - verifyStartedAt}ms`) + this.trace(` ${describeLinkCandidate(candidate)}: verification threw after ${Date.now() - verifyStartedAt}ms`) return { ok: false, error: describeError(error) } } } @@ -530,7 +564,7 @@ export class DeviceSessionManager { const verdict = await this.probeVerdict(client, candidate) const decision = this.policy.onProbeResult(verdict) if (verdict !== 'alive') { - this.trace(`poll: ${candidate.transport} ${candidate.descriptor} ${verdict} -> ${decision}`) + this.trace(`poll: ${describeLinkCandidate(candidate)} ${verdict} -> ${decision}`) } switch (decision) { case 'enter-recovery': diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index a9196d313..fc924d860 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -219,6 +219,23 @@ describe('generateDefinesContent — Debugger block (always-on debug)', () => { expect(out).toContain('#define DEBUG_BAUD 9600') }) + // The reported failure, end to end: Modbus off, 9600 saved on the screen. The + // editor dials 9600 (spec params ignore `enabledWhen`), so a firmware built at + // 115200 opened the port and answered nothing — "No Firmware Detected" on a + // healthy board. + it('aligns DEBUG_BAUD with the screen baud when Modbus is DISABLED', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } }, + }) + expect(out).toContain('#define DEBUGGER_ENABLED') + expect(out).toContain('#define DEBUG_BAUD 9600') + // Modbus itself stays out of the build. + expect(out).not.toContain('#define MODBUS_ENABLED') + }) + it('keeps DEBUG_BAUD at the firmware default when the RTU has its own second port', () => { const out = generateDefinesContent({ ...EMPTY_INPUTS, diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index 90aa9e3c4..deeb036f9 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -43,8 +43,24 @@ describe('resolveDebugBaud', () => { ) }) - it('ignores the RTU baud when the RTU is disabled', () => { - expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } })).toBe(DEFAULT_DEBUG_BAUD) + // The editor dials `rtu_baud_rate` whether or not the RTU is enabled — a debug + // spec's `params` are read independently of its `enabledWhen`. So the firmware + // must listen there too, or a project with Modbus turned off and a non-default + // baud saved on the screen is unreachable. + it('still takes the RTU baud when the RTU is DISABLED', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } })).toBe('9600') + }) + + it('takes the RTU baud when the RTU is disabled and names a second port', () => { + // The rate is unused by Modbus, and the debugger owns the default port. What + // decides this is what the editor dials, which is this value. + expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_interface: 'Serial1', rtu_baud_rate: '9600' } })).toBe( + '9600', + ) + }) + + it('falls back when the RTU section states no baud at all', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true } })).toBe(DEFAULT_DEBUG_BAUD) }) it('falls back for an empty project', () => { diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index d2cd340b3..11e657f3d 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -80,38 +80,43 @@ export interface VppModbusScreenState { export const DEFAULT_DEBUG_BAUD = '115200' /** - * Baud rate the DEFAULT serial port comes up at — the one the always-on - * debugger answers on, and therefore the one the editor must dial to reach it. + * Baud rate the DEFAULT serial port comes up at — the one the always-on debugger + * answers on, and therefore the one the editor must dial to reach it. * - * Three sources, in order: + * The two sides derive this independently (the firmware from here, the editor + * from the board's `debug` spec), so they have to agree or the port opens and + * decodes nothing. What the editor dials is + * `screens.modbus_rtu.rtu_baud_rate` — ALWAYS, whether or not the RTU is + * enabled, because a spec's `params` are read independently of its + * `enabledWhen`. This function mirrors that: * - * 1. The `serial` section, when a package declares one. It exists precisely to - * configure this port independently of Modbus. - * 2. Otherwise, the RTU's baud — but ONLY when the RTU is on the default port, - * because there the firmware brings that one port up at `MBSERIAL_BAUD` and - * the debugger shares it (`MBSERIAL_SHARES_DEBUG_SERIAL`). Honouring it is - * what keeps firmware and editor on the same wire speed for every package - * published without a `serial` section. - * 3. Otherwise `115200`: either no RTU at all, or an RTU on a SECOND UART while - * the debugger keeps the default port to itself. Nothing in the project - * states that port's speed, so the firmware's default is the only answer — - * which is why the connect flow probes alternative bauds rather than - * trusting this one blindly. + * 1. A `serial` section, when a package declares one — it exists precisely to + * configure this port, and a package that has it also points its debug spec + * at it. + * 2. Otherwise the RTU's baud, which for a package published today is the only + * serial speed the project states at all. This holds even when the RTU is + * DISABLED: the rate is then unused by Modbus, but the editor still dials it, + * so the firmware had better listen there. + * 3. `115200` only when the RTU is enabled on a SECOND UART — the one case where + * that rate genuinely belongs to a different port and the debugger keeps the + * default one to itself. Nothing states that port's speed, so this is a + * guess, and it is exactly the case the connect flow's baud sweep exists for. */ export function resolveDebugBaud(state: VppModbusScreenState, defaultSerial: string = 'Serial'): string { const declared = state.serial?.baud_rate if (declared) return declared const rtu = state.modbus_rtu - if (rtu?.enabled === true) { + if (!rtu) return DEFAULT_DEBUG_BAUD + + // An enabled RTU on its own UART takes its baud with it; the debugger is then + // on a port whose speed the project never mentions. + if (rtu.enabled === true) { const iface = rtu.serial_port ?? rtu.rtu_interface ?? defaultSerial - if (iface === defaultSerial) { - const shared = rtu.baud_rate ?? rtu.rtu_baud_rate - if (shared) return shared - } + if (iface !== defaultSerial) return DEFAULT_DEBUG_BAUD } - return DEFAULT_DEBUG_BAUD + return rtu.baud_rate ?? rtu.rtu_baud_rate ?? DEFAULT_DEBUG_BAUD } /** diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index dc314589c..5e3b09ace 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -55,6 +55,7 @@ import { SPECULATIVE_BOARD_ID_PROBE, } from '../../../backend/editor/hardware/device-probe' import { + describeLinkCandidate, type DeviceDebugCandidate, type DeviceLinkCandidate, type DeviceLinkStatus, @@ -1983,12 +1984,14 @@ class MainProcessBridge implements MainIpcModule { // Probe the params now so a malformed config fails resolution rather than // becoming a candidate that always throws on `create()`. if ('error' in buildDeviceModbusTransport(params, options)) return - const base = describeDebugEndpoint(config) ;(isGuess ? speculative : declared).push({ transport: kind, - // Name the rate on a guess: the trace and the failure dialog list what was - // tried, and "COM5" five times over tells the user nothing. - descriptor: isGuess ? `${base} @ ${baudRate} baud` : base, + // The endpoint ONLY. It is matched against the OS port list and against + // the port an upload asks to borrow, so the baud travels beside it rather + // than inside it — decorating this string made every swept candidate match + // no port and be skipped in 1ms. + descriptor: describeDebugEndpoint(config), + baudRate, speculative: isGuess, create: () => { const built = buildDeviceModbusTransport(params, options) @@ -2294,7 +2297,7 @@ class MainProcessBridge implements MainIpcModule { // better message ("no firmware" beats "could not connect"); otherwise report // what was tried. if (probe && probe.status !== 'connected-with-firmware') return probe - const tried = result.attempts.map((attempt) => `${attempt.descriptor}: ${attempt.error}`).join('; ') + const tried = result.attempts.map((attempt) => `${describeLinkCandidate(attempt)}: ${attempt.error}`).join('; ') return { status: 'no-response', error: tried || 'No connection could be established.' } } From 474c149bb8f142d167e890a6429e2afcf76cc55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 14:57:04 +0200 Subject: [PATCH 23/79] fix(compile): name precompiled objects `.cpp.o` so esp8266 links them into flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link failure was not a size problem in the usual sense — it was 7387 bytes of ordinary code running from the wrong memory. esp8266's linker script decides flash-vs-IRAM by matching the OBJECT NAME: .irom0.text : { *.c.o(.literal* .text*) *.cpp.o(EXCLUDE_FILE (umm_malloc.cpp.o) .literal* … .text*) *.cc.o(.literal* .text*) … explicit list of SDK archives … } Anything unmatched falls through to `.text1`, a catch-all mapped into `iram1_0_seg` — 32 KB shared with the WiFi/SDK core. libOpenPLCUserLib.a is not in the archive list, so its members had to match `*.cpp.o` to reach flash. The precompile stripped the extension (`basename.replace(/\.cpp$/, '.o')`), naming them `arduino_runtime_glue.o` / `configuration.o` / `pou_MAIN.o` — none of which match anything, so every translation unit of the user library was placed in IRAM. Measured on the failing project (objdump -h over the archive): arduino_runtime_glue.o 3781 bytes -> IRAM configuration.o 3149 bytes -> IRAM pou_MAIN.o 457 bytes -> IRAM ------------------------------------------ 7387 bytes of 32768 Keeping the `.cpp` (`arduino_runtime_glue.cpp.o`) is the same convention arduino-cli uses for sketch objects — which is exactly why every `modbus_*.cpp.o` in the sketch was already in flash while the archive was not. Pre-existing, and independent of any board: the naming has always been this way. It surfaced now because the run/stop state machine grew arduino_runtime_glue.cpp, making the largest IRAM squatter larger still and pushing an ESP8266 NodeMCU over the segment. The reported error named neither the archive nor the cause: section `.text1' will not fit in region `iram1_0_seg' Covered by a test, because the symptom is this remote from the cause: the compile command must carry `arduino_runtime_glue.cpp.o` and must not carry the bare `.o` form. Co-Authored-By: Claude Opus 5 --- .../handle-precompile-user-lib.test.ts | 28 +++++++++++++++++++ .../editor/compiler/compiler-module.ts | 20 ++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts b/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts index 458a83c8c..6f29d2fb8 100644 --- a/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts @@ -129,6 +129,34 @@ describe('handlePrecompileUserLib include-path injection', () => { expect(compileCmd).toContain('-I/fake/renesas/variants/UNOWIFIR4') }) + // esp8266 decides flash-vs-IRAM by matching the OBJECT NAME in its linker + // script (`*.cpp.o(.literal* .text*)` -> flash). Named `foo.o`, every TU of + // libOpenPLCUserLib.a missed that match and fell into `.text1`, a catch-all + // mapped into the 32 KB `iram1_0_seg` shared with the WiFi/SDK core — + // measured at 7387 bytes for a small project, which overflowed the segment + // and failed the link with "section `.text1' will not fit in region + // `iram1_0_seg'", naming neither this archive nor the cause. + it('names objects `.cpp.o` so esp8266 links them into flash, not IRAM', async () => { + fs.writeFileSync(join(srcDir, 'arduino_runtime_glue.cpp'), 'void glue() {}\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:renesas_uno:unor4wifi', + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('arduino_runtime_glue.cpp')) ?? '' + expect(compileCmd).toContain('arduino_runtime_glue.cpp.o') + // The bare `.o` form is what the esp8266 flash matcher misses. + expect(compileCmd).not.toMatch(/[/\\]arduino_runtime_glue\.o\b/) + }) + it('omits the variant -I when build.variant.path is unset (runtime-only / minimalist cores)', async () => { extractSpy.mockResolvedValue({ ...baseProps, diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 99ccebb69..0aaf17a3e 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1463,7 +1463,25 @@ class CompilerModule { // Build the .o path list synchronously up-front so the archive members // land in source-file order regardless of the concurrent compile result. - const objectFiles = sources.map((sourcePath) => join(objDir, path.basename(sourcePath).replace(/\.cpp$/, '.o'))) + // + // The `.cpp` is KEPT in the object name (`foo.cpp.o`, not `foo.o`) — the same + // convention arduino-cli uses for sketch objects, and on ESP8266 it decides + // whether the code runs from flash or from IRAM. + // + // esp8266's linker script sends code to flash by matching the OBJECT NAME: + // + // .irom0.text : { *.c.o(.literal* .text*) + // *.cpp.o(EXCLUDE_FILE (umm_malloc.cpp.o) .literal* … .text*) + // *.cc.o(.literal* .text*) … } + // + // Anything it does not match falls through to `.text1`, a catch-all mapped + // into `iram1_0_seg` — 32 KB shared with the WiFi/SDK core. Named `foo.o`, + // every translation unit of libOpenPLCUserLib.a landed there: measured at + // 7387 bytes of IRAM for a small project (glue 3781 + configuration 3149 + + // pou_MAIN 457), which overflowed the segment and failed the link with + // "section `.text1' will not fit in region `iram1_0_seg'" — a message that + // names neither this archive nor the reason. + const objectFiles = sources.map((sourcePath) => join(objDir, `${path.basename(sourcePath)}.o`)) // Cap concurrent toolchain spawns at the host's logical core count. // An unbounded `sources.map(async …)` was dispatching one g++ per TU From 976ed4c38ad26d1b84ec7646706a3a528c426b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 15:32:38 +0200 Subject: [PATCH 24/79] fix(editor): stop the liveness poll from killing a busy debug session, and quiet its trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems from the same source: the debug poll is by far the busiest thing on the link, and both the liveness check and the trace treated each of its requests as an event worth acting on. THE DISCONNECT. Every command queues on the ONE serial link (the RTU client serialises requests, correctly — two frames in flight on a half-duplex wire would corrupt each other). A debug session reads variables continuously, so on a slow wire the status read (FC 0x46) waits behind that queue and times out. Two timeouts enter recovery, recovery drops the client, and the renderer ends the debug session — over a link that was working the whole time. The reported trace says exactly this: `unresponsive -> continue` twice, then `enter-recovery`, with successful variable reads interleaved throughout. Roughly every ten seconds on a 9600-baud ESP8266. The fix is to stop asking a question already answered: a command that succeeded IS liveness evidence, so `noteTraffic()` records it and the poll skips its round trip when something spoke within the interval. Cheaper than the old behaviour and strictly better evidence — it happened, rather than being asked for. Deliberately kept: - the serial-port presence check runs FIRST, before the shortcut. It reads the OS port list, costs nothing, and a yanked cable must still be caught on the next tick rather than hidden by a stale timestamp; - `dropClient()` clears the stamp, so traffic over a client that is gone cannot vouch for its replacement and recovery still probes for real; - a fresh connection does NOT seed the stamp: nothing else is on the link yet, so the first tick does its own read and the poll behaves exactly as before. The trade: while a debug session is running, run/stop state refreshes only when the poll actually reads (the status frame is where it comes from). A Start/Stop button that lags a hand-flipped switch during a debug session is a far better outcome than a debug session that dies every ten seconds. THE LOG FLOOD. `traceChannelUse` fired on every channel acquisition, which for the debug poll is several times a second: ~8 identical `read variables: using the debug channel (rtu COM5)` lines per second, burying every other message — including the ones explaining the disconnect. The question it answers ("did run/stop really ride the same connection as the debugger?") is answered by the FIRST occurrence, so it is now logged once per distinct command+channel+endpoint, and the set is cleared on connect and disconnect so a new session says it again. Co-Authored-By: Claude Opus 5 --- .../__tests__/device-session-manager.test.ts | 71 +++++++++++++++++++ .../editor/hardware/device-session-manager.ts | 37 +++++++++- src/main/modules/ipc/main.ts | 26 ++++++- 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts index 6b0b89a81..e04ede5a7 100644 --- a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -170,6 +170,77 @@ describe('DeviceSessionManager', () => { } }) + // A debug session polls variables continuously, and every request queues on + // the ONE serial link. On a slow wire the liveness read waits behind that + // traffic and times out; two such timeouts entered recovery and tore down a + // debug session whose own reads were succeeding — measured at roughly every + // ten seconds on a 9600-baud ESP8266. Traffic IS liveness evidence. + it('treats recent successful traffic as liveness instead of polling the busy link', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return false + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + const afterOpen = probes + + // The debugger just read something successfully. + h.manager.noteTraffic() + await h.manager.tick() + + // No probe was sent, and a probe that WOULD have failed did not count. + expect(probes).toBe(afterOpen) + expect(h.statuses.map((s) => s.status)).not.toContain('error') + expect(h.manager.getClient()).toBe(asTransport(client)) + h.manager.close() + }) + + it('polls normally on a fresh connection, before any traffic', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + await h.manager.tick() + + // Nothing else is on the link yet, so the poll does its own read. + expect(probes).toBe(1) + h.manager.close() + }) + + it('resumes polling once the traffic evidence has aged out', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + h.manager.noteTraffic() + await h.manager.tick() + expect(probes).toBe(0) + + // A whole interval with nothing on the link: the poll must ask again. + jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000) + await h.manager.tick() + expect(probes).toBe(1) + + jest.restoreAllMocks() + h.manager.close() + }) + it('falls back to serial when Modbus TCP cannot connect', async () => { const h = harness() const tcp = new FakeClient({ connectFails: true }) diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 43394e398..d1cb8c725 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -227,6 +227,8 @@ export class DeviceSessionManager { private readonly policy: DeviceLinkPolicy private timer: ReturnType | null = null private tickInFlight = false + /** When a command last succeeded over the link. 0 = nothing since it opened. */ + private lastTrafficAt = 0 constructor( private readonly hooks: DeviceLinkHooks, @@ -239,6 +241,25 @@ export class DeviceSessionManager { this.hooks.log?.(message) } + /** + * A command just succeeded over the held link. + * + * This is liveness evidence, and better evidence than the poll's own read: it + * already happened, and it cost nothing extra. The poll uses it to skip its + * round trip entirely (see `probeVerdict`). + * + * Why that matters, not merely as an optimisation: a debug session polls + * variables continuously, and every request queues on the ONE serial link. + * On a slow wire the status read waits behind that traffic, times out, and two + * such timeouts enter recovery — tearing down a debug session over a link that + * was demonstrably working, which is what the traffic proves. Measured on a + * 9600-baud ESP8266: the debugger died roughly every ten seconds while its own + * reads kept succeeding. + */ + noteTraffic(): void { + this.lastTrafficAt = Date.now() + } + /** * The CONTROL channel's client, or null when nothing is connected (including * mid-recovery). Run/stop and the status poll go here. @@ -377,6 +398,10 @@ export class DeviceSessionManager { this.client = outcome.client this.current = candidate this.policy.reset() + // Deliberately NOT seeding `lastTrafficAt` from the verify that just + // passed: nothing else is on the link yet, so letting the first tick do + // its own read costs nothing and keeps the poll's behaviour on a fresh + // connection exactly as it was. this.startPolling() this.hooks.emit({ status: 'connected', @@ -530,6 +555,8 @@ export class DeviceSessionManager { private dropClient(): void { this.client?.disconnect() this.client = null + // Traffic over a client we just dropped proves nothing about the next one. + this.lastTrafficAt = 0 } private startPolling(): void { @@ -588,10 +615,18 @@ export class DeviceSessionManager { ): Promise<'alive' | 'unresponsive' | 'gone'> { // Check the endpoint first: a pulled USB cable is not a slow device, and // treating it as one would spend the whole failure budget waiting for - // timeouts on a port that no longer exists. + // timeouts on a port that no longer exists. Deliberately BEFORE the traffic + // shortcut below: the port list is local and instant, so a yanked cable is + // still caught on the very next tick. if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { return 'gone' } + // Something already answered within this interval, so the link is up and + // asking again would only add traffic to a wire that is evidently busy — + // and on a slow one, queue behind it and time out. See `noteTraffic`. + if (this.lastTrafficAt > 0 && Date.now() - this.lastTrafficAt < this.timings.pollIntervalMs) { + return 'alive' + } try { return (await this.hooks.probe(client)) ? 'alive' : 'unresponsive' } catch { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 5e3b09ace..8fe5277d8 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1812,6 +1812,10 @@ class MainProcessBridge implements MainIpcModule { try { const result = await link.client.getVariablesList(variableIndexes) if (result.success && result.data) { + // The debug poll is the busiest thing on the link; telling the session + // about it is what stops the liveness read from queueing behind this + // traffic and timing out on a link that is plainly working. + this.deviceSession.noteTraffic() return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } } return { success: false, error: result.error } @@ -2224,9 +2228,25 @@ class MainProcessBridge implements MainIpcModule { return acquired } + /** + * Which channel served which command — logged ONCE per distinct combination. + * + * The question this answers ("did run/stop really ride the same connection as + * the debugger?") is answered by the first occurrence. Logging every occurrence + * answered it several times a second: the debug poll reads variables + * continuously, so an unfiltered trace emitted ~8 identical lines per second + * and buried every other message in the console, including the ones explaining + * a disconnect. + */ + private tracedChannelUses = new Set() + private traceChannelUse(what: string, family: 'control' | 'debug'): void { const link = this.deviceSession.getLink() - this.traceDeviceLink(`${what}: using the ${family} channel (${link?.transport ?? '?'} ${link?.descriptor ?? '?'})`) + const endpoint = `${link?.transport ?? '?'} ${link?.descriptor ?? '?'}` + const key = `${what}|${family}|${endpoint}` + if (this.tracedChannelUses.has(key)) return + this.tracedChannelUses.add(key) + this.traceDeviceLink(`${what}: using the ${family} channel (${endpoint})`) } private explainMissingChannel(what: string): ChannelUnavailable { @@ -2276,6 +2296,9 @@ class MainProcessBridge implements MainIpcModule { candidates: DebugConnectionConfig[], ): Promise => { this.deviceLinkProbe = null + // A new connection is a new story: let each command say which channel served + // it again, since it may well be a different one this time. + this.tracedChannelUses.clear() this.traceDeviceLink( `connect requested with ${candidates.length} candidate(s): ${ candidates.map((config) => `${config.connectionType} ${describeDebugEndpoint(config)}`).join(', ') || '(none)' @@ -2305,6 +2328,7 @@ class MainProcessBridge implements MainIpcModule { handleDeviceDisconnect = async (): Promise<{ success: boolean }> => { this.deviceSession.close() this.deviceLinkProbe = null + this.tracedChannelUses.clear() return { success: true } } From 2596f1eee379ea24fa0a6773fb5121e8330117a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 15:56:15 +0200 Subject: [PATCH 25/79] fix(editor): a firmware that reports no unique id is still a firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found reviewing the branch, not in the field — but it is the same false negative the last three commits were chasing, and it would have hit a whole class of boards. `classifyDeviceLink` required the FC 0x48 reply to carry a non-empty board id. `success: true` already means the frame came back with the right function code and a SUCCESS status, which only an OpenPLC firmware sends; the id is separate. Cores without ArduinoUniqueID support, and boards that opt out with `OPENPLC_NO_UNIQUE_ID`, deliberately answer `id_len = 0` rather than fail to compile — see `debugGetBoardId` in modbus_debug.cpp, which documents exactly that. So every such board was classified `no-firmware`: "No Firmware Detected", with an offer to reflash a device that was running and answering. It also burned the full patient budget (6 reads, ~32s) before saying so, and then swept every fallback baud rate, because none of them could satisfy a condition the firmware can never meet. The requirement came from `probeAndRecover`, where it was correct — licensing needs the anchor bytes, so an empty id really is a dead end there. Carrying it into connect classification was the mistake. Also: `setVariable` now notes traffic like `getVariablesList` does. Forcing values queues on the same link and proves the same liveness, so a force held while the poll is due no longer lets the status read wait behind it and time out. Co-Authored-By: Claude Opus 5 --- .../hardware/__tests__/device-probe.test.ts | 15 +++++++++++++-- src/backend/editor/hardware/device-probe.ts | 17 +++++++++++++---- src/main/modules/ipc/main.ts | 4 ++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/device-probe.test.ts b/src/backend/editor/hardware/__tests__/device-probe.test.ts index 192040d79..55024374e 100644 --- a/src/backend/editor/hardware/__tests__/device-probe.test.ts +++ b/src/backend/editor/hardware/__tests__/device-probe.test.ts @@ -104,10 +104,21 @@ describe('classifyDeviceLink', () => { expect(result).toEqual({ status: 'no-firmware' }) }) - it('reports no-firmware when the frame came back with an empty id', async () => { + // Cores without ArduinoUniqueID, and boards opting out via + // OPENPLC_NO_UNIQUE_ID, answer FC 0x48 with `id_len = 0` on purpose rather than + // failing to compile. That is a firmware replying, not a blank board — treating + // the empty id as "no firmware" told those users to reflash a working device. + it('keeps a firmware that answers with no unique id at all', async () => { const result = await classifyDeviceLink(fakeChannel([EMPTY_ID]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) - expect(result).toEqual({ status: 'no-firmware' }) + expect(result).toEqual({ status: 'connected-with-firmware' }) + }) + + it('does not burn retries once a firmware has answered, empty id or not', async () => { + const channel = fakeChannel([EMPTY_ID]) + await classifyDeviceLink(channel, { boardIdProbe: { attempts: 6, backoffMs: 0 } }) + + expect(channel.calls).toHaveLength(1) }) it('never throws — a transport that blows up resolves to an error status', async () => { diff --git a/src/backend/editor/hardware/device-probe.ts b/src/backend/editor/hardware/device-probe.ts index 6ab3006b6..99babf7d4 100644 --- a/src/backend/editor/hardware/device-probe.ts +++ b/src/backend/editor/hardware/device-probe.ts @@ -121,7 +121,14 @@ export async function connectWithRetries(client: Connectable, { attempts, backof /** * Read the board id (FC 0x48) with a bounded retry/backoff loop -- a readiness * probe for the firmware itself (the serial open auto-resets ESP8266/AVR boards). - * A non-empty board id means a firmware answered. + * + * A SUCCESSFUL REPLY is the signal, not a non-empty id. `success` already means + * the frame came back with the right function code and a SUCCESS status, which + * only an OpenPLC firmware sends. The id itself is allowed to be empty: cores + * without ArduinoUniqueID support, and boards that opt out with + * `OPENPLC_NO_UNIQUE_ID`, deliberately answer `id_len = 0` rather than fail to + * compile (see `debugGetBoardId` in modbus_debug.cpp). Requiring bytes here + * reported those boards as having no firmware at all. */ export async function readBoardIdWithRetries( client: BoardIdReadable, @@ -131,7 +138,7 @@ export async function readBoardIdWithRetries( for (let attempt = 0; attempt < attempts; attempt++) { const result = await client.getBoardId() last = { success: result.success, boardId: result.boardId } - if (last.success && !!last.boardId && last.boardId.length > 0) return last + if (last.success) return last if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, backoffMs)) } return last @@ -160,8 +167,10 @@ export async function classifyDeviceLink( ): Promise { try { const probe = await readBoardIdWithRetries(client, opts.boardIdProbe ?? PATIENT_BOARD_ID_PROBE) - if (!probe.success || !probe.boardId || probe.boardId.length === 0) { - // Channel opened but nothing spoke the debug protocol -> blank/non-OpenPLC. + if (!probe.success) { + // Channel opened but nothing spoke the debug protocol -> blank board, a + // non-OpenPLC device, or the wrong baud rate. Whether the reply carried a + // unique id is NOT part of this question — see `readBoardIdWithRetries`. return { status: 'no-firmware' } } return { status: 'connected-with-firmware' } diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 8fe5277d8..bebd36247 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -2347,6 +2347,10 @@ class MainProcessBridge implements MainIpcModule { try { const result = await link.client.setVariable(variableIndex, force, buffer) + // Forcing values is device traffic too: it queues on the same link and + // proves the same thing a read does. Without this, holding a force while + // the poll is due lets the liveness read wait behind it and time out. + if (result.success) this.deviceSession.noteTraffic() logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) return result } catch (error) { From 579cc05a83ac5a5d8eb138d830f7ee8d9c6bb75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 18:09:49 +0200 Subject: [PATCH 26/79] fix(connection): reconnect after upload through the device port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silent post-upload reconnect called `window.bridge.deviceConnect` directly. That global only exists in the Electron preload, so a shared component reaching for it does not compile in openplc-web — the byte-identical mirror gate has no way to satisfy both repos. `device.connect(...)` is the same call: the editor's device adapter delegates straight to `window.bridge.deviceConnect`. The component already holds the port (`const device = useDevice()`) and already uses it a few lines up for `releaseSerialPort`, so this closes the one place the layer was bypassed. Co-Authored-By: Claude Opus 5 --- .../components/_organisms/workspace-activity-bar/default.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index df4f97cd6..ca57c6bc3 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -376,7 +376,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }) if (candidates.kind === 'candidates') { try { - await window.bridge.deviceConnect(candidates.candidates.map((candidate) => candidate.config)) + await device.connect(candidates.candidates.map((candidate) => candidate.config)) } catch { // best-effort: the user can press Connect again. } From 4e11afa999a33c7036e91d37f94bcad730e09534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 4 Aug 2026 14:31:58 -0300 Subject: [PATCH 27/79] fix(graphical): stop an invalid flow from blocking saves and history (DOPE-495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the flow write-back fix. Three consequences of sweeping every `updated` flow rather than only the ones with a live timer: Deleting a POU leaves its flow behind — `deleteElement` clears the project entry, model, file and tab, but not the flow, and `removeLadderFlow` has one caller (the AI tool executor). So an invalid orphan was reported stale on every later save and `success` stayed false for good: builds blocked, "save and close" never completing, and a toast naming a POU that no longer exists. Deleting the POU is the user's only escape hatch from a corrupted flow, and it was the one path still broken. A flow with no POU has no body to write back, so skip it. `executeSaveFile` flushed every dirty graphical POU on a single-file save. Nothing was misreported, but it validated and warned about POUs the user was not saving. Scope it to the target. undo, redo and snapshot capture discarded the flush result, which restored a file to "saved" over a body that never reached disk — the original bug by another route, and worse: it clears `updated`, so nothing retries. All three now bail. `undo`/`redo` return a boolean so the accelerator handler can raise a "History unavailable" toast instead of letting the shortcut look broken; `captureAndPush` stays silent because it fires on every edit. Follow-ups, including the `deleteElement` leak this only treats: DOPE-524. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VKiQpeqRqne8nLj2fUGH21 --- .../_templates/accelerator-handler.tsx | 18 +++++++--- src/frontend/hooks/use-pou-snapshot.ts | 5 +-- .../services/__tests__/save-actions.test.ts | 33 +++++++++++++++++++ src/frontend/services/save-actions.ts | 12 ++++--- .../store/__tests__/shared-slice.test.ts | 29 ++++++++++++++++ .../store/slices/shared/flow-writeback.ts | 8 ++++- src/frontend/store/slices/shared/slice.ts | 16 +++++---- src/frontend/store/slices/shared/types.ts | 6 ++-- 8 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/frontend/components/_templates/accelerator-handler.tsx b/src/frontend/components/_templates/accelerator-handler.tsx index 1d5136a1e..a28bd40d5 100644 --- a/src/frontend/components/_templates/accelerator-handler.tsx +++ b/src/frontend/components/_templates/accelerator-handler.tsx @@ -288,21 +288,31 @@ const AcceleratorHandler = () => { /** * Undo / Redo */ + // An invalid graphical body blocks history changes outright, so say so rather + // than letting the shortcut look broken (DOPE-495). + const notifyStaleBody = useCallback((pouName: string) => { + toast({ + title: 'History unavailable', + description: `The graphical body of "${pouName}" is invalid, so its history was not changed.`, + variant: 'fail', + }) + }, []) + useEffect(() => { const unsub = accelerator.onUndo(() => { if (!meta?.name) return - undo(meta.name) + if (!undo(meta.name)) notifyStaleBody(meta.name) }) return unsub - }, [meta.name, isMonacoFocused, accelerator, undo]) + }, [meta.name, isMonacoFocused, accelerator, undo, notifyStaleBody]) useEffect(() => { const unsub = accelerator.onRedo(() => { if (!meta?.name) return - redo(meta.name) + if (!redo(meta.name)) notifyStaleBody(meta.name) }) return unsub - }, [meta.name, isMonacoFocused, accelerator, redo]) + }, [meta.name, isMonacoFocused, accelerator, redo, notifyStaleBody]) /** * Quit app (Ctrl+Q on Windows/Linux) diff --git a/src/frontend/hooks/use-pou-snapshot.ts b/src/frontend/hooks/use-pou-snapshot.ts index 1ba92a930..e7f715e90 100644 --- a/src/frontend/hooks/use-pou-snapshot.ts +++ b/src/frontend/hooks/use-pou-snapshot.ts @@ -24,8 +24,9 @@ export function usePouSnapshot() { const captureAndPush = useCallback( (pouName: string) => { // A debounced graphical write-back may still be pending — flush it so - // the snapshot can't pair a stale body with a fresh flow. - flushFlowWriteBacks(useOpenPLCStore.getState, pouName) + // the snapshot can't pair a stale body with a fresh flow. A failed flush + // leaves the body stale, so there is nothing coherent to capture. + if (flushFlowWriteBacks(useOpenPLCStore.getState, pouName).length > 0) return const { project, ladderFlows, fbdFlows } = useOpenPLCStore.getState() const pou = project.data.pous.find((p) => p.name === pouName) if (!pou) return diff --git a/src/frontend/services/__tests__/save-actions.test.ts b/src/frontend/services/__tests__/save-actions.test.ts index 6ce34a3f8..f1f67823a 100644 --- a/src/frontend/services/__tests__/save-actions.test.ts +++ b/src/frontend/services/__tests__/save-actions.test.ts @@ -100,6 +100,23 @@ describe('save-actions', () => { expect(flowUpdated('GoodPou')).toBe(false) expect(fileSaved('GoodPou')).toBe(true) }) + + it('stops blocking saves once the POU behind an invalid flow is deleted', async () => { + createLadderPou('Doomed') + corruptFlow('Doomed') + createLadderPou('Healthy') + + expect((await executeSaveProject(makeProjectPort(), capabilities)).success).toBe(false) + + // Deleting the POU is the user's only escape hatch, and it leaves the + // flow behind — the save must stop reporting it. + openPLCStoreBase.getState().pouActions.delete('Doomed') + + const result = await executeSaveProject(makeProjectPort(), capabilities) + + expect(result.success).toBe(true) + expect(fileSaved('Healthy')).toBe(true) + }) }) describe('executeSaveFile', () => { @@ -125,5 +142,21 @@ describe('save-actions', () => { expect(projectPort.saveFile).toHaveBeenCalled() expect(flowUpdated('ValidFile')).toBe(false) }) + + it('leaves an unrelated failing POU untouched', async () => { + createLadderPou('TargetFile') + createLadderPou('Unrelated') + corruptFlow('Unrelated') + + const projectPort = makeProjectPort() + const result = await executeSaveFile('TargetFile', projectPort, capabilities) + + expect(result.success).toBe(true) + expect(projectPort.saveFile).toHaveBeenCalled() + // The flush is scoped to the target, so the unrelated flow is never + // validated and never warns. + expect(warn).not.toHaveBeenCalled() + expect(flowUpdated('Unrelated')).toBe(true) + }) }) }) diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 723416e55..6e852ddf0 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -439,6 +439,8 @@ export async function executeSaveProject( deleted: deletionsBeforeSave, }) + const isStale = new Set(staleFlows) + state.projectActions.clearPendingDeletions() setEditingState(staleFlows.length > 0 ? 'unsaved' : 'saved') setAllToSaved() @@ -449,14 +451,15 @@ export async function executeSaveProject( // would strand the in-memory edit with no way back to disk. for (const flow of state.ladderFlows) { state.ladderFlowActions.clearSelections({ editorName: flow.name }) - if (staleFlows.includes(flow.name)) continue + if (isStale.has(flow.name)) continue state.ladderFlowActions.setFlowUpdated({ editorName: flow.name, updated: false }) } for (const flow of state.fbdFlows) { state.fbdFlowActions.clearSelections({ editorName: flow.name }) - if (staleFlows.includes(flow.name)) continue + if (isStale.has(flow.name)) continue state.fbdFlowActions.setFlowUpdated({ editorName: flow.name, updated: false }) } + // Must stay after `setAllToSaved()` above, which marks every file saved. for (const name of staleFlows) { updateFile({ name, saved: false }) } @@ -512,8 +515,9 @@ export async function executeSaveFile( projectPort: ProjectPort, capabilities: PlatformCapabilities, ): Promise<{ success: boolean }> { - // See executeSaveProject — same pending write-back flush requirement. - const staleFlows = flushFlowWriteBacks(openPLCStoreBase.getState) + // See executeSaveProject — same pending write-back flush requirement, scoped + // to the target so a single-file save doesn't touch unrelated POUs. + const staleFlows = flushFlowWriteBacks(openPLCStoreBase.getState, fileName) const state = openPLCStoreBase.getState() // See executeSaveProject for rationale — same persist gate. if (!state.workspace.canEdit) { diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index f55b72520..87c7f22c0 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -8,6 +8,7 @@ import { createEditorSlice } from '../slices/editor/slice' import { createFBDFlowSlice } from '../slices/fbd/slice' import { createFileSlice } from '../slices/file/slice' import { createHistorySlice } from '../slices/history/slice' +import type { LadderFlowType } from '../slices/ladder' import { createLadderFlowSlice } from '../slices/ladder/slice' import { createLibrarySlice } from '../slices/library/slice' import { createModalSlice } from '../slices/modal/slice' @@ -1104,6 +1105,34 @@ describe('createSharedSlice', () => { expect(store.getState().undoRedo['Main'].past).toHaveLength(0) }) + it('does nothing when the POU flow fails its write-back', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + store.getState().pouActions.create({ type: 'program', name: 'Graphical', language: 'ld' }) + store.getState().snapshotActions.pushToHistory('Graphical', snapshot1) + // `rungs` entries without `defaultBounds` fail the ladder schema, so the + // body stays stale and a snapshot here would pair it with a fresh flow. + store.getState().ladderFlowActions.addLadderFlow({ + name: 'Graphical', + updated: true, + rungs: [{ id: 'r1', comment: '', nodes: [], edges: [] }], + } as unknown as LadderFlowType) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'Graphical', updated: true }) + + // `false` is what drives the "History unavailable" toast in the UI. + expect(store.getState().snapshotActions.undo('Graphical')).toBe(false) + expect(store.getState().snapshotActions.redo('Graphical')).toBe(false) + + expect(store.getState().undoRedo['Graphical'].past).toHaveLength(1) + expect(store.getState().undoRedo['Graphical'].future).toHaveLength(0) + warn.mockRestore() + }) + + it('reports success when there is simply nothing to undo', () => { + expect(store.getState().snapshotActions.undo('Main')).toBe(true) + store.getState().snapshotActions.pushToHistory('Main', snapshot1) + expect(store.getState().snapshotActions.undo('Main')).toBe(true) + }) + it('undo falls back to empty array when POU has no interface', () => { // Manually strip the POU interface to test the ?? [] fallback const pous = store.getState().project.data.pous.map((p) => { diff --git a/src/frontend/store/slices/shared/flow-writeback.ts b/src/frontend/store/slices/shared/flow-writeback.ts index 605d54d9e..b439232b0 100644 --- a/src/frontend/store/slices/shared/flow-writeback.ts +++ b/src/frontend/store/slices/shared/flow-writeback.ts @@ -96,17 +96,23 @@ export function scheduleFlowWriteBack(getState: GetWriteBackState, pouName: stri * * @returns names of POUs whose body could not be updated. */ -export function flushFlowWriteBacks(getState: GetWriteBackState, pouName?: string): string[] { +export function flushFlowWriteBacks(getState: GetWriteBackState, pouName?: string): readonly string[] { cancelFlowWriteBacks(pouName) const state = getState() + // Deleting a POU leaves its flow behind, so an invalid one would be reported + // stale on every future save — blocking saves and builds for good. It has no + // body left to write back, so it isn't a write-back failure. + const livePous = new Set(state.project.data.pous.map((pou) => pou.name)) const failed: string[] = [] for (const flow of state.ladderFlows) { if (pouName !== undefined && flow.name !== pouName) continue + if (!livePous.has(flow.name)) continue if (flow.updated && !runWriteBack(getState, flow.name, 'ld')) failed.push(flow.name) } for (const flow of state.fbdFlows) { if (pouName !== undefined && flow.name !== pouName) continue + if (!livePous.has(flow.name)) continue if (flow.updated && !runWriteBack(getState, flow.name, 'fbd')) failed.push(flow.name) } return failed diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 8c07a150f..cc2b78116 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -993,14 +993,16 @@ const createSharedSlice: StateCreator = (s undo: (pouName) => { // A debounced graphical write-back may still be pending — flush it so // the redo snapshot below can't pair a stale body with a fresh flow. - flushFlowWriteBacks(getState, pouName) + // A failed flush leaves the body stale, and capturing it would restore + // the file to "saved" over content that never reached disk (DOPE-495). + if (flushFlowWriteBacks(getState, pouName).length > 0) return false const state = getState() const history = state.undoRedo[pouName] - if (!history || history.past.length === 0) return + if (!history || history.past.length === 0) return true const snapshot = history.past[history.past.length - 1] const pou = state.project.data.pous.find((p) => p.name === pouName) - if (!pou) return + if (!pou) return true // Save current state to future. Plain references — the store is // immer-managed (frozen, copy-on-write), so later edits can never @@ -1046,18 +1048,19 @@ const createSharedSlice: StateCreator = (s if (afterUndo?.savedAtDepth !== null && afterUndo?.savedAtDepth === afterUndo?.past.length) { getState().fileActions.updateFile({ name: pouName, saved: true }) } + return true }, redo: (pouName) => { // See undo — same pending write-back consistency requirement. - flushFlowWriteBacks(getState, pouName) + if (flushFlowWriteBacks(getState, pouName).length > 0) return false const state = getState() const history = state.undoRedo[pouName] - if (!history || history.future.length === 0) return + if (!history || history.future.length === 0) return true const snapshot = history.future[history.future.length - 1] const pou = state.project.data.pous.find((p) => p.name === pouName) - if (!pou) return + if (!pou) return true // Save current state to past. Plain references — see undo. const currentSnapshot: PouHistorySnapshot = { @@ -1101,6 +1104,7 @@ const createSharedSlice: StateCreator = (s if (afterRedo?.savedAtDepth !== null && afterRedo?.savedAtDepth === afterRedo?.past.length) { getState().fileActions.updateFile({ name: pouName, saved: true }) } + return true }, }, }) diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index f3d3b824c..5025c4129 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -118,8 +118,10 @@ export type SnapshotActions = { pushToHistory: (pouName: string, snapshot: PouHistorySnapshot) => void markSaved: (pouName: string) => void markAllSaved: (except?: readonly string[]) => void - undo: (pouName: string) => void - redo: (pouName: string) => void + /** @returns `false` when the POU's graphical body is stale, so history was left untouched. */ + undo: (pouName: string) => boolean + /** @returns `false` when the POU's graphical body is stale, so history was left untouched. */ + redo: (pouName: string) => boolean } export type OpenProjectResponseData = { From 1c8ee641feb37c05ff3d38c01b08ab1a88ea8034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 21:43:01 +0200 Subject: [PATCH 28/79] refactor(connection): trace the outcome, not every step towards it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful connect wrote nine lines. Eight of them said the same thing the ninth already said, and the console it shares with the compiler and the debugger scrolled past whatever the user was actually reading. What is left is two lines: what was requested, and what happened. Silenced, all of it happy-path only: * `status -> connecting` — a transient already visible in the button, emitted once per candidate. On a baud sweep that is five identical lines around the one outcome worth reading. * `transport opened in Xms` — opening a port is a step towards the answer, not the answer. Failing to open still reports. * `answered the debug protocol in Xms` / `ACCEPTED in Xms` — both restate the `connected` status that immediately follows, with the same descriptor. * `classified as "connected-with-firmware"` — the interesting classifications are the other ones. * `open: N candidate(s) in order` — now deferred to the moment a fallback is actually in play (the second candidate). On the ordinary connection the first one answers, and listing four baud rates nobody will dial buries the rest. Every line that reports a PROBLEM is untouched: port not enumerated, transport would not open, candidate rejected with its elapsed time, verification threw. The negative classification is now more useful than before, because the probe budget moved onto it: COM5: "no-firmware" after up to 2 id read(s) (baud guess) COM5: "no-firmware" after up to 6 id read(s) (last configured endpoint, was patient) "No firmware after two reads at a rate I guessed" is a different problem from "no firmware after six on the port the project configured", and the old line announced that budget BEFORE the outcome it explains — on every success too. These traces are what surfaced the four field defects on this branch, so the aim is to quiet the successful case, not to lose the evidence. Co-Authored-By: Claude Opus 5 --- .../editor/hardware/device-session-manager.ts | 33 ++++++++++------- src/main/modules/ipc/main.ts | 37 ++++++++++++------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index d1cb8c725..5e6b90e3c 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -381,19 +381,28 @@ export class DeviceSessionManager { this.candidates = candidates const attempts: DeviceLinkOpenFailure['attempts'] = [] - this.trace(`open: ${candidates.length} candidate(s) in order: ${candidates.map(describeLinkCandidate).join(', ')}`) for (const [index, candidate] of candidates.entries()) { + // The plan is only worth stating once something went wrong. On the ordinary + // connection the first candidate answers, and listing four baud rates nobody + // will dial just buries the two lines that matter. The moment a fallback IS + // in play, the order becomes the thing you need to read. + if (index === 1) { + this.trace( + `open: falling back — ${candidates.length} candidate(s): ${candidates.map(describeLinkCandidate).join(', ')}`, + ) + } this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) const startedAt = Date.now() const outcome = await this.tryCandidate(candidate, { isLastCandidate: index === candidates.length - 1 }) - const elapsed = Date.now() - startedAt - this.trace( - outcome.ok - ? `open: ${describeLinkCandidate(candidate)} ACCEPTED in ${elapsed}ms` - : `open: ${describeLinkCandidate(candidate)} rejected in ${elapsed}ms — ${outcome.error}`, - ) + // Only a rejection is traced. Acceptance is already announced by the + // `connected` status that follows, with the same descriptor. + if (!outcome.ok) { + this.trace( + `open: ${describeLinkCandidate(candidate)} rejected in ${Date.now() - startedAt}ms — ${outcome.error}`, + ) + } if (outcome.ok) { this.client = outcome.client this.current = candidate @@ -474,8 +483,9 @@ export class DeviceSessionManager { const connectStartedAt = Date.now() try { + // Nothing traced on success: opening is a step towards the answer, not the + // answer. Only failing to open is news. await client.connect() - this.trace(` ${describeLinkCandidate(candidate)}: transport opened in ${Date.now() - connectStartedAt}ms`) } catch (error) { client.disconnect() this.trace( @@ -489,12 +499,7 @@ export class DeviceSessionManager { // so this is the step that decides whether to keep the candidate. const verifyStartedAt = Date.now() try { - if (await this.hooks.verify(client, candidate, context)) { - this.trace( - ` ${describeLinkCandidate(candidate)}: answered the debug protocol in ${Date.now() - verifyStartedAt}ms`, - ) - return { ok: true, client } - } + if (await this.hooks.verify(client, candidate, context)) return { ok: true, client } client.disconnect() this.trace( ` ${describeLinkCandidate(candidate)}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index bebd36247..09a3ef041 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1938,11 +1938,17 @@ class MainProcessBridge implements MainIpcModule { /** Push a link state change to the renderer. */ private emitDeviceLinkStatus(status: DeviceLinkStatus): void { - this.traceDeviceLink( - `status -> ${status.status}${status.descriptor ? ` (${status.transport ?? '?'} ${status.descriptor})` : ''}${ - status.reason ? ` [${status.reason}]` : '' - }`, - ) + // `connecting` is not traced. It is a transient the user can already see in the + // button, and it repeats once per candidate — on a baud sweep that is five + // identical lines around the one outcome worth reading. Every settled state + // (connected / disconnected / error) still gets its line. + if (status.status !== 'connecting') { + this.traceDeviceLink( + `status -> ${status.status}${status.descriptor ? ` (${status.transport ?? '?'} ${status.descriptor})` : ''}${ + status.reason ? ` [${status.reason}]` : '' + }`, + ) + } this.mainWindow?.webContents?.send('device:connection-status', status) } @@ -2141,16 +2147,21 @@ class MainProcessBridge implements MainIpcModule { : isPatient ? PATIENT_BOARD_ID_PROBE : QUICK_BOARD_ID_PROBE - this.traceDeviceLink( - ` ${candidate.descriptor}: verifying with up to ${boardIdProbe.attempts} id read(s)` + - `${candidate.speculative ? ' (baud guess)' : isPatient ? ' (last configured endpoint, being patient)' : ''}`, - ) const result = await classifyDeviceLink(client, { boardIdProbe }) this.deviceLinkProbe = result - this.traceDeviceLink( - ` ${candidate.descriptor}: classified as "${result.status}"${result.error ? ` (${result.error})` : ''}`, - ) - if (result.status !== 'connected-with-firmware') return false + if (result.status !== 'connected-with-firmware') { + // Traced only when the endpoint is REJECTED, and then with the budget it was + // given: "no firmware after 2 id reads (baud guess)" is a different problem + // from "no firmware after 6" on the port the project configured. Announcing + // the budget up front, as this used to, put the line before the outcome it + // explains and printed it on every success too. + this.traceDeviceLink( + ` ${candidate.descriptor}: "${result.status}" after up to ${boardIdProbe.attempts} id read(s)` + + `${candidate.speculative ? ' (baud guess)' : isPatient ? ' (last configured endpoint, was patient)' : ''}` + + `${result.error ? ` — ${result.error}` : ''}`, + ) + return false + } // The status frame doubles as the run/stop state source; push it straight // away so the Start/Stop button is right before the first poll lands. await this.pushPlcState(client, candidate.descriptor, 0) From 32e324c7c8278cbf95e8b519fb6b935312ffcfc4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 4 Aug 2026 15:56:41 -0400 Subject: [PATCH 29/79] chore(strucpp): pin v0.6.2 for located-global sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.6.2 emits `locatedGlobals[]` — the storage pointers of every located CONFIGURATION VAR_GLOBAL — plus C-linkage accessors, so the runtime can identify which entries of `locatedVars[]` are configuration-scope by pointer identity instead of inferring it from their position in the array. The runtime's inference was backwards (STruC++ emits config globals first, not last), so a single located variable declared in a POU made the runtime conclude the project had zero located globals and silently stop syncing all of them. Reported on the forum as "%MX locations now invalid - Runtime v4": Modbus coil writes reached the image but never the program. Located %IX/%QX/%MW globals were equally affected. v0.6.2 also tightens located-address validation, which REJECTS projects that previously compiled: - a location declared on anything other than VAR / VAR_GLOBAL (the editor already blocks these classes at edit and load time, so editor-authored projects are unaffected; hand-written ST may not be), - a located variable in a PROGRAM instantiated more than once, - a POU-local location colliding with a located CONFIGURATION VAR_GLOBAL. Each of those previously produced a program that looked fine and did not work, so the errors surface pre-existing breakage rather than new restrictions. Pin only — no editor code changes are needed, and APP_VERSION is deliberately untouched so this can batch with whatever else lands before the next release. Co-Authored-By: Claude Opus 5 (1M context) --- binary-versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/binary-versions.json b/binary-versions.json index 53d60cdb5..d43af3c10 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -1,6 +1,6 @@ { "strucpp": { - "version": "v0.6.1", + "version": "v0.6.2", "repository": "Autonomy-Logic/STruCpp" } } From 32dd32512afd951ebf8aae7f17cd54ad43448ca5 Mon Sep 17 00:00:00 2001 From: JulioSergioFS Date: Tue, 4 Aug 2026 17:19:44 -0300 Subject: [PATCH 30/79] fix(runtime): give login its own 15s timeout for slow devices --- src/main/modules/ipc/main.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index e0f8cc87b..7679f1ef6 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -133,6 +133,7 @@ class MainProcessBridge implements MainIpcModule { // ===================== RUNTIME API HANDLERS ===================== private readonly RUNTIME_API_PORT = 8443 private readonly RUNTIME_CONNECTION_TIMEOUT_MS = 5000 // 5 seconds (important-comment) + private readonly RUNTIME_LOGIN_TIMEOUT_MS = 15000 // 15 seconds /** * Low-level HTTP helper that handles data accumulation, timeout, and error handling. @@ -143,6 +144,7 @@ class MainProcessBridge implements MainIpcModule { url: string body?: string headers?: Record + timeoutMs?: number }): Promise<{ statusCode: number; data: string; headers: IncomingHttpHeaders }> { return new Promise((resolve, reject) => { const parsedUrl = new URL(options.url) @@ -172,7 +174,7 @@ class MainProcessBridge implements MainIpcModule { resolve({ statusCode: res.statusCode ?? 0, data, headers: res.headers }) }) }) - req.setTimeout(this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.setTimeout(options.timeoutMs ?? this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { req.destroy() reject(new Error('Connection timeout')) }) @@ -297,6 +299,7 @@ class MainProcessBridge implements MainIpcModule { method: 'POST', url: this.runtimeUrl(ipAddress, '/api/login'), body: JSON.stringify({ username, password }), + timeoutMs: this.RUNTIME_LOGIN_TIMEOUT_MS, }) if (res.statusCode === 200) { try { From 710be5440d628df4ec1f0cd30b297bbd51619be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 11:37:12 -0300 Subject: [PATCH 31/79] feat(datatypes): add canonical single-type ST serializer and .dt text parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for storing data types as datatypes/.dt files (DOPE-385): serializeDataTypeToText frames one type per TYPE…END_TYPE block, and the new parseDataTypeFromText is its round-trip inverse — round-trip shapes only, with a name-must-match-file rule shared by the loader and the future code view. Serializer fidelity fixes: struct array fields rebuilt from their structured shape instead of the lossy display value, field documentation preserved as a trailing comment, multi-dimension arrays emitted in IEC comma form. DOPE-530 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019f6Kfu7zebQC3UdeN29tiq --- .../__tests__/data-type-serializer.test.ts | 77 ++++++- .../__tests__/data-type-text-parser.test.ts | 198 ++++++++++++++++++ .../utils/PLC/data-type-serializer.ts | 28 ++- .../utils/PLC/data-type-text-parser.ts | 157 ++++++++++++++ .../utils/generate-iec-string-to-variables.ts | 3 +- 5 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts create mode 100644 src/frontend/utils/PLC/data-type-text-parser.ts diff --git a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts index 1d27d1331..26f79c4d3 100644 --- a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts +++ b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts @@ -11,7 +11,7 @@ * one of these up. */ import type { PLCDataType } from '../../../../middleware/shared/ports/types' -import { serializeDataTypesToLines, serializeDataTypesToST } from '../data-type-serializer' +import { serializeDataTypesToLines, serializeDataTypesToST, serializeDataTypeToText } from '../data-type-serializer' const enumerated = (name: string, values: string[], initialValue?: string): PLCDataType => ({ name, @@ -88,8 +88,62 @@ describe('serializeDataTypesToST', () => { }) it('serialises a multi-dimension array with an initial value', () => { + // IEC 61131-3 multi-dimension syntax: one bracket, comma-separated. const out = serializeDataTypesToST([array('Matrix', 'REAL', ['0..3', '0..3'], '[0,0,0,0]')]) - expect(out).toBe('TYPE\n Matrix : ARRAY [0..3][0..3] OF REAL := [0,0,0,0];\nEND_TYPE\n') + expect(out).toBe('TYPE\n Matrix : ARRAY [0..3, 0..3] OF REAL := [0,0,0,0];\nEND_TYPE\n') + }) + + it('rebuilds structure array fields from their structured shape', () => { + const dt: PLCDataType = { + name: 'Rec', + derivation: 'structure', + variable: [ + { + name: 'samples', + type: { + definition: 'array', + value: 'ARRAY [1..5, 1..3] OF INT', + data: { + baseType: { definition: 'base-type', value: 'INT' }, + dimensions: [{ dimension: '1..5' }, { dimension: '1..3' }], + }, + }, + }, + // Legacy records may carry an array definition without the + // structured `data` — the display value is all we have. + { name: 'legacy', type: { definition: 'array', value: 'ARRAY [0..1] OF BOOL' } }, + ], + } + expect(serializeDataTypesToST([dt])).toBe( + 'TYPE\n' + + ' Rec : STRUCT\n' + + ' samples : ARRAY [1..5, 1..3] OF INT;\n' + + ' legacy : ARRAY [0..1] OF BOOL;\n' + + ' END_STRUCT;\n' + + 'END_TYPE\n', + ) + }) + + it('renders structure field documentation as a trailing comment', () => { + const dt: PLCDataType = { + name: 'Motor', + derivation: 'structure', + variable: [ + { + name: 'speed', + type: { definition: 'base-type', value: 'INT' }, + initialValue: { simpleValue: { value: '100' } }, + documentation: 'target speed in rpm', + }, + ], + } + expect(serializeDataTypesToST([dt])).toBe( + 'TYPE\n' + + ' Motor : STRUCT\n' + + ' speed : INT := 100; (* target speed in rpm *)\n' + + ' END_STRUCT;\n' + + 'END_TYPE\n', + ) }) it('packs multiple entries in one block in declaration order', () => { @@ -145,7 +199,7 @@ describe('serializeDataTypesToLines', () => { it('reports a single line for an array (regardless of dimensions)', () => { const entries = serializeDataTypesToLines([array('Buffer', 'INT', ['0..9', '0..3'])]) - expect(entries).toEqual([{ name: 'Buffer', lines: [' Buffer : ARRAY [0..9][0..3] OF INT;'] }]) + expect(entries).toEqual([{ name: 'Buffer', lines: [' Buffer : ARRAY [0..9, 0..3] OF INT;'] }]) }) it('drops zero-line entries from the result', () => { @@ -154,6 +208,23 @@ describe('serializeDataTypesToLines', () => { expect(entries).toEqual([{ name: 'Color', lines: [' Color : (Red);'] }]) }) + it('serializeDataTypeToText frames a single entry as its own TYPE block', () => { + expect(serializeDataTypeToText(enumerated('Color', ['Red', 'Green'], 'Red'))).toBe( + 'TYPE\n Color : (Red, Green) := Red;\nEND_TYPE\n', + ) + expect(serializeDataTypeToText(structure('Point', [{ name: 'x', type: 'INT' }]))).toBe( + 'TYPE\n Point : STRUCT\n x : INT;\n END_STRUCT;\nEND_TYPE\n', + ) + expect(serializeDataTypeToText(array('Buffer', 'INT', ['0..9']))).toBe( + 'TYPE\n Buffer : ARRAY [0..9] OF INT;\nEND_TYPE\n', + ) + }) + + it('serializeDataTypeToText returns an empty string for an unknown derivation', () => { + const unknown = { name: 'Mystery', derivation: 'pointer' } as unknown as PLCDataType + expect(serializeDataTypeToText(unknown)).toBe('') + }) + it('lines from serializeDataTypesToLines roundtrip into serializeDataTypesToST', () => { // Lock the invariant: the flat ST output is exactly TYPE + // join(lines, '\n') + END_TYPE. goto-definition-redirect diff --git a/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts b/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts new file mode 100644 index 000000000..519b47ba7 --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Tests for the `.dt` text parser — the inverse of + * `serializeDataTypeToText`. The core invariant is the round-trip: + * `parse(serialize(x))` must deep-equal `x` for every shape the + * visual editor can build. Everything else is error reporting. + */ +import type { PLCDataType } from '../../../../middleware/shared/ports/types' +import { serializeDataTypeToText } from '../data-type-serializer' +import { parseDataTypeFromText } from '../data-type-text-parser' + +const roundTrip = (dt: PLCDataType) => parseDataTypeFromText(serializeDataTypeToText(dt), dt.name) + +describe('parseDataTypeFromText round-trips', () => { + it('round-trips an enumerated type without an initial value', () => { + const dt: PLCDataType = { + name: 'Color', + derivation: 'enumerated', + values: [{ description: 'Red' }, { description: 'Green' }, { description: 'Blue' }], + initialValue: '', + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips an enumerated type with an initial value', () => { + const dt: PLCDataType = { + name: 'Mode', + derivation: 'enumerated', + values: [{ description: 'Auto' }, { description: 'Manual' }], + initialValue: 'Auto', + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips an empty enumeration (freshly created in the UI)', () => { + const dt: PLCDataType = { name: 'Empty', derivation: 'enumerated', values: [], initialValue: '' } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips a structure with base, user, and array fields', () => { + const dt: PLCDataType = { + name: 'Motor', + derivation: 'structure', + variable: [ + { name: 'speed', type: { definition: 'base-type', value: 'INT' } }, + { name: 'status', type: { definition: 'user-data-type', value: 'MotorState' } }, + { + name: 'samples', + type: { + definition: 'array', + value: 'ARRAY [1..5, 1..3] OF INT', + data: { + baseType: { definition: 'base-type', value: 'INT' }, + dimensions: [{ dimension: '1..5' }, { dimension: '1..3' }], + }, + }, + }, + ], + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips structure field initial values and documentation', () => { + const dt: PLCDataType = { + name: 'Config', + derivation: 'structure', + variable: [ + { + name: 'rate', + type: { definition: 'base-type', value: 'INT' }, + initialValue: { simpleValue: { value: '100' } }, + documentation: 'sampling rate in ms', + }, + { + name: 'enabled', + type: { definition: 'base-type', value: 'BOOL' }, + initialValue: { simpleValue: { value: 'TRUE' } }, + }, + ], + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips an empty structure (freshly created in the UI)', () => { + const dt: PLCDataType = { name: 'Shell', derivation: 'structure', variable: [] } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips a single-dimension array', () => { + const dt: PLCDataType = { + name: 'Buffer', + derivation: 'array', + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '', + dimensions: [{ dimension: '0..9' }], + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips a multi-dimension array of a user type with an initial value', () => { + const dt: PLCDataType = { + name: 'Grid', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'Cell' }, + initialValue: '[c1, c2]', + dimensions: [{ dimension: '0..3' }, { dimension: '0..3' }], + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) +}) + +describe('parseDataTypeFromText tolerance', () => { + it('accepts keyword-case and whitespace variations', () => { + const text = 'type\r\n\r\n color : (Red, Green) ;\r\nend_type\r\n' + const result = parseDataTypeFromText(text) + expect(result.error).toBeUndefined() + expect(result.dataType).toEqual({ + name: 'color', + derivation: 'enumerated', + values: [{ description: 'Red' }, { description: 'Green' }], + initialValue: '', + }) + }) + + it('accepts a case-insensitive name match and normalises to the expected name', () => { + const result = parseDataTypeFromText('TYPE\n color : (Red);\nEND_TYPE\n', 'Color') + expect(result.error).toBeUndefined() + expect(result.dataType?.name).toBe('Color') + }) + + it('accepts END_STRUCT with spaced semicolon and lowercase struct keywords', () => { + const text = 'TYPE\n Point : struct\n x : int;\n end_struct ;\nEND_TYPE\n' + const result = parseDataTypeFromText(text, 'Point') + expect(result.error).toBeUndefined() + expect(result.dataType).toEqual({ + name: 'Point', + derivation: 'structure', + variable: [{ name: 'x', type: { definition: 'base-type', value: 'INT' } }], + }) + }) +}) + +describe('parseDataTypeFromText errors', () => { + it('rejects an empty file', () => { + expect(parseDataTypeFromText('').error).toMatch(/empty file/) + expect(parseDataTypeFromText(' \n \n').error).toMatch(/empty file/) + }) + + it('rejects a missing TYPE frame', () => { + expect(parseDataTypeFromText('Color : (Red);\nEND_TYPE\n').error).toMatch(/must start with a TYPE/) + expect(parseDataTypeFromText('TYPE\n Color : (Red);\n').error).toMatch(/must end with END_TYPE/) + }) + + it('rejects a TYPE block with no declaration', () => { + expect(parseDataTypeFromText('TYPE\nEND_TYPE\n').error).toMatch(/declares no data type/) + }) + + it('rejects more than one declaration per file', () => { + const twoEnums = 'TYPE\n A : (X);\n B : (Y);\nEND_TYPE\n' + expect(parseDataTypeFromText(twoEnums).error).toMatch(/exactly one data type/) + const structPlusEnum = 'TYPE\n P : STRUCT\n x : INT;\n END_STRUCT;\n B : (Y);\nEND_TYPE\n' + expect(parseDataTypeFromText(structPlusEnum).error).toMatch(/exactly one data type/) + }) + + it('rejects a structure without END_STRUCT', () => { + expect(parseDataTypeFromText('TYPE\n P : STRUCT\n x : INT;\nEND_TYPE\n').error).toMatch(/missing END_STRUCT/) + }) + + it('rejects invalid structure fields with a hint', () => { + const missingSemicolon = 'TYPE\n P : STRUCT\n x : INT\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(missingSemicolon).error).toMatch(/missing semicolon/) + const missingColon = 'TYPE\n P : STRUCT\n x INT;\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(missingColon).error).toMatch(/missing colon/) + const badFieldType = 'TYPE\n P : STRUCT\n x : MY TYPE;\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(badFieldType).error).toMatch(/invalid structure field/) + }) + + it('rejects invalid enumeration values', () => { + expect(parseDataTypeFromText('TYPE\n Color : (Red, 2bad);\nEND_TYPE\n').error).toMatch(/invalid enumeration value/) + }) + + it('rejects unrecognized declarations with a hint', () => { + expect(parseDataTypeFromText('TYPE\n Foo\nEND_TYPE\n').error).toMatch(/missing semicolon/) + expect(parseDataTypeFromText('TYPE\n Foo Bar;\nEND_TYPE\n').error).toMatch(/missing colon/) + expect(parseDataTypeFromText('TYPE\n Foo : ?!;\nEND_TYPE\n').error).toMatch(/unrecognized declaration format/) + }) + + it('rejects an invalid type name', () => { + expect(parseDataTypeFromText('TYPE\n 2bad : (Red);\nEND_TYPE\n').error).toMatch(/invalid type name/) + }) + + it('rejects a declared name that does not match the expected name', () => { + const result = parseDataTypeFromText('TYPE\n Other : (Red);\nEND_TYPE\n', 'Color') + expect(result.error).toMatch(/does not match the expected name "Color"/) + expect(result.error).toMatch(/rename the data type via the project tree/) + }) +}) diff --git a/src/frontend/utils/PLC/data-type-serializer.ts b/src/frontend/utils/PLC/data-type-serializer.ts index a6e7b9305..016ce3f1e 100644 --- a/src/frontend/utils/PLC/data-type-serializer.ts +++ b/src/frontend/utils/PLC/data-type-serializer.ts @@ -35,6 +35,12 @@ import type { PLCDataType, PLCVariableType } from '../../../middleware/shared/ports/types' function renderVariableType(type: PLCVariableType): string { + // Rebuild array types from their structured shape — `value` is a + // display string that legacy records may hold in a lossy form. + if (type.definition === 'array' && type.data) { + const dims = type.data.dimensions.map((d) => d.dimension).join(', ') + return `ARRAY [${dims}] OF ${type.data.baseType.value}` + } return type.value } @@ -47,15 +53,17 @@ function renderEnumeratedLines(dt: Extract): string[] { const fieldLines = dt.variable.map((v) => { const init = v.initialValue?.simpleValue?.value ? ` := ${v.initialValue.simpleValue.value}` : '' - return ` ${v.name} : ${renderVariableType(v.type)}${init};` + const doc = v.documentation ? ` (* ${v.documentation} *)` : '' + return ` ${v.name} : ${renderVariableType(v.type)}${init};${doc}` }) return [` ${dt.name} : STRUCT`, ...fieldLines, ` END_STRUCT;`] } function renderArrayLines(dt: Extract): string[] { - const dims = dt.dimensions.map((d) => `[${d.dimension}]`).join('') + // IEC 61131-3 multi-dimension syntax: one bracket, comma-separated. + const dims = dt.dimensions.map((d) => d.dimension).join(', ') const initial = dt.initialValue ? ` := ${dt.initialValue}` : '' - return [` ${dt.name} : ARRAY ${dims} OF ${renderVariableType(dt.baseType)}${initial};`] + return [` ${dt.name} : ARRAY [${dims}] OF ${renderVariableType(dt.baseType)}${initial};`] } function renderDataTypeLines(dt: PLCDataType): string[] { @@ -115,3 +123,17 @@ export function serializeDataTypesToST(dataTypes: PLCDataType[]): string { const body = entries.flatMap((e) => e.lines).join('\n') return `TYPE\n${body}\nEND_TYPE\n` } + +/** + * Serialise ONE data type to its on-disk `.dt` file content — a + * `TYPE…END_TYPE` block holding a single declaration. This is the + * canonical persistence format (`datatypes/.dt`); its inverse + * is `parseDataTypeFromText` in `data-type-text-parser.ts`, and the + * pair must round-trip. Returns `''` for a derivation that renders + * to no lines (unknown shape). + */ +export function serializeDataTypeToText(dt: PLCDataType): string { + const lines = renderDataTypeLines(dt) + if (lines.length === 0) return '' + return `TYPE\n${lines.join('\n')}\nEND_TYPE\n` +} diff --git a/src/frontend/utils/PLC/data-type-text-parser.ts b/src/frontend/utils/PLC/data-type-text-parser.ts new file mode 100644 index 000000000..558954b4f --- /dev/null +++ b/src/frontend/utils/PLC/data-type-text-parser.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Parse a single data-type declaration from its on-disk `.dt` text + * (an ST `TYPE…END_TYPE` block) back into a `PLCDataType`. + * + * Inverse of `serializeDataTypeToText` in `data-type-serializer.ts`. + * The parser accepts exactly the shapes the serializer emits — + * tolerant to whitespace and keyword case — so `parse(serialize(x))` + * round-trips. Anything the visual editor cannot represent is a + * parse error, never a silent drop: the code view keeps the user on + * the text until it is valid, and the project loader falls back to + * preserving the raw file content. + * + * When `expectedName` is given, the declared name must match it + * (case-insensitive) — the file name is the type's identity, so + * renaming happens through the project tree, not by editing text. + */ +import { baseTypeSchema } from '../../../middleware/shared/ports/plc-schemas' +import type { PLCDataType, PLCStructureVariable, PLCVariableType } from '../../../middleware/shared/ports/types' +import { parseArrayType } from '../generate-iec-string-to-variables' + +export interface ParseDataTypeResult { + dataType?: PLCDataType + error?: string +} + +const identifierRegex = /^[A-Za-z_]\w*$/ + +const structStartRegex = /^(?\w+)\s*:\s*STRUCT$/i + +const structEndRegex = /^END_STRUCT\s*;$/i + +// Name : (Value1, Value2) := Initial ; +const enumRegex = /^(?\w+)\s*:\s*\((?[^)]*)\)\s*(?::=\s*(?[^;]+?))?\s*;$/ + +// Name : ARRAY [d1, d2] OF Base := Initial ; +const arrayRegex = + /^(?\w+)\s*:\s*(?ARRAY\s*\[[^\]]+\]\s+OF\s+[A-Za-z_][\w.]*)\s*(?::=\s*(?[^;]+?))?\s*;$/i + +// FieldName : Type := Initial ; (* documentation *) +const fieldRegex = + /^(?\w+)\s*:\s*(?[\w\s[\],.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ + +const guessErrorReason = (line: string): string => { + if (!line.includes(';')) return 'missing semicolon (;) at the end of the declaration' + if (!line.includes(':')) return 'missing colon (:) between name and type' + return 'unrecognized declaration format' +} + +function buildFieldType(typeStr: string): PLCVariableType | null { + const arrayType = parseArrayType(typeStr) + if (arrayType) return arrayType + const baseCheck = baseTypeSchema.safeParse(typeStr) + if (baseCheck.success) return { definition: 'base-type', value: baseCheck.data } + if (identifierRegex.test(typeStr)) return { definition: 'user-data-type', value: typeStr } + return null +} + +function parseStructure(name: string, body: string[]): ParseDataTypeResult { + const endIndex = body.findIndex((line) => structEndRegex.test(line)) + if (endIndex === -1) return { error: 'missing END_STRUCT; to close the structure' } + if (endIndex !== body.length - 1) return { error: 'a .dt file must declare exactly one data type' } + + const variable: PLCStructureVariable[] = [] + for (const line of body.slice(1, endIndex)) { + const match = fieldRegex.exec(line) + const groups = match?.groups + const type = groups?.type !== undefined ? buildFieldType(groups.type) : null + if (groups?.name === undefined || type === null) { + return { error: `invalid structure field: "${line}". Possible cause: ${guessErrorReason(line)}` } + } + variable.push({ + name: groups.name, + type, + ...(groups.initial !== undefined ? { initialValue: { simpleValue: { value: groups.initial.trim() } } } : {}), + ...(groups.documentation !== undefined && groups.documentation !== '' + ? { documentation: groups.documentation } + : {}), + }) + } + return { dataType: { name, derivation: 'structure', variable } } +} + +function parseSingleLine(line: string): ParseDataTypeResult { + const enumMatch = enumRegex.exec(line) + if (enumMatch?.groups?.name !== undefined) { + const raw = enumMatch.groups.values.trim() + const values = raw === '' ? [] : raw.split(',').map((v) => v.trim()) + const invalid = values.find((v) => !identifierRegex.test(v)) + if (invalid !== undefined) return { error: `invalid enumeration value: "${invalid}"` } + return { + dataType: { + name: enumMatch.groups.name, + derivation: 'enumerated', + values: values.map((description) => ({ description })), + initialValue: enumMatch.groups.initial?.trim() ?? '', + }, + } + } + + const arrayMatch = arrayRegex.exec(line) + if (arrayMatch?.groups?.name !== undefined) { + const arrayType = parseArrayType(arrayMatch.groups.type) + if (arrayType?.data) { + return { + dataType: { + name: arrayMatch.groups.name, + derivation: 'array', + baseType: arrayType.data.baseType, + initialValue: arrayMatch.groups.initial?.trim() ?? '', + dimensions: arrayType.data.dimensions, + }, + } + } + } + + return { error: `invalid declaration: "${line}". Possible cause: ${guessErrorReason(line)}` } +} + +export function parseDataTypeFromText(content: string, expectedName?: string): ParseDataTypeResult { + const lines = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== '') + + if (lines.length === 0) return { error: 'empty file — expected a TYPE…END_TYPE declaration' } + if (!/^TYPE$/i.test(lines[0])) return { error: 'the declaration must start with a TYPE block' } + if (!/^END_TYPE$/i.test(lines[lines.length - 1])) return { error: 'the declaration must end with END_TYPE' } + + const body = lines.slice(1, -1) + if (body.length === 0) return { error: 'the TYPE block declares no data type' } + + const structMatch = structStartRegex.exec(body[0]) + const structName = structMatch?.groups?.name + const result = + structName !== undefined + ? parseStructure(structName, body) + : body.length > 1 + ? { error: 'a .dt file must declare exactly one data type' } + : parseSingleLine(body[0]) + + if (result.dataType === undefined) return result + if (!identifierRegex.test(result.dataType.name)) { + return { error: `invalid type name: "${result.dataType.name}"` } + } + + if (expectedName !== undefined) { + if (result.dataType.name.toLowerCase() !== expectedName.toLowerCase()) { + return { + error: `declared type name "${result.dataType.name}" does not match the expected name "${expectedName}" — rename the data type via the project tree instead`, + } + } + result.dataType.name = expectedName + } + return result +} diff --git a/src/frontend/utils/generate-iec-string-to-variables.ts b/src/frontend/utils/generate-iec-string-to-variables.ts index 53915b241..ac50b424d 100644 --- a/src/frontend/utils/generate-iec-string-to-variables.ts +++ b/src/frontend/utils/generate-iec-string-to-variables.ts @@ -56,8 +56,9 @@ const hasLibraryPous = (lib: unknown): lib is { pous: Array<{ name: string; type /** * Parse an array type string like "ARRAY[1..10] OF INT" or "ARRAY[1..10, 1..5] OF MyStruct" * Returns null if not an array type, otherwise returns the parsed array type definition. + * Also consumed by the data-type text parser (`PLC/data-type-text-parser.ts`). */ -const parseArrayType = (typeStr: string): PLCVariable['type'] | null => { +export const parseArrayType = (typeStr: string): PLCVariable['type'] | null => { // Match ARRAY[dimensions] OF baseType, where baseType is an identifier (optionally namespaced) const arrayMatch = typeStr.match(/^ARRAY\s*\[([^\]]+)\]\s+OF\s+([A-Za-z_][\w.]*)\s*$/i) if (!arrayMatch) return null From a5ac7321a210f022fc365fffc96a2b46143e8d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 16:38:01 -0300 Subject: [PATCH 32/79] feat(datatypes): add datatypes/*.dt file-category plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the data-type file category end to end without switching any write behavior on (DOPE-385 groundwork): dataTypeFiles joins the WriteProjectFiles/RawProjectFiles port contracts as a required field, the shared write iterator gains the data-type category, the raw project reader walks datatypes/ collecting .dt files into their own bucket (never pouFiles — pou-path detection throws outside the pou folders), and the writer's mkdir list gains the directory. datatypes/ joins the default project directories and project.json's dataTypes becomes schema-defaulted so future files can omit it. Also hardens the DOPE-530 surface per review: struct field names are validated and field documentation newlines are collapsed at serialization. DOPE-532 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019f6Kfu7zebQC3UdeN29tiq --- .../editor/services/project-service/index.ts | 14 ++++++++--- .../iterate-write-project-files.test.ts | 23 ++++++++++++++++++- .../project/iterate-write-project-files.ts | 4 ++++ .../shared/project/project-files-schema.ts | 5 +++- src/backend/shared/types/PLC/open-plc.ts | 5 +++- src/frontend/services/save-actions.ts | 2 ++ .../__tests__/data-type-serializer.test.ts | 17 ++++++++++++++ .../__tests__/data-type-text-parser.test.ts | 5 ++++ .../utils/PLC/data-type-serializer.ts | 4 +++- .../utils/PLC/data-type-text-parser.ts | 3 +++ .../editor/__tests__/project-adapter.test.ts | 2 ++ src/middleware/shared/ports/project-port.ts | 7 ++++++ 12 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/backend/editor/services/project-service/index.ts b/src/backend/editor/services/project-service/index.ts index 00f102408..dfda0b932 100644 --- a/src/backend/editor/services/project-service/index.ts +++ b/src/backend/editor/services/project-service/index.ts @@ -192,6 +192,7 @@ class ProjectService { pouFiles: Array<{ relativePath: string; content: string }> serverFiles: Array<{ relativePath: string; content: string }> remoteDeviceFiles: Array<{ relativePath: string; content: string }> + dataTypeFiles: Array<{ relativePath: string; content: string }> } error?: { title: string; description: string } }> { @@ -242,6 +243,7 @@ class ProjectService { const readDirRecursive = async ( dirPath: string, basePath: string, + validExtensions: string[] = VALID_POU_EXTENSIONS, ): Promise> => { const results: Array<{ relativePath: string; content: string }> = [] try { @@ -250,11 +252,11 @@ class ProjectService { const fullPath = join(dirPath, entry.name) const relPath = join(basePath, entry.name) if (entry.isDirectory()) { - const subResults = await readDirRecursive(fullPath, relPath) + const subResults = await readDirRecursive(fullPath, relPath, validExtensions) results.push(...subResults) } else if (entry.isFile()) { const ext = entry.name.slice(entry.name.lastIndexOf('.')) - if (!VALID_POU_EXTENSIONS.includes(ext)) continue + if (!validExtensions.includes(ext)) continue const content = await promises.readFile(fullPath, 'utf-8') results.push({ relativePath: relPath, content }) } @@ -285,6 +287,11 @@ class ProjectService { const serverFiles = await readDirRecursive(join(projectPath, 'devices', 'servers'), 'devices/servers') const remoteDeviceFiles = await readDirRecursive(join(projectPath, 'devices', 'remote'), 'devices/remote') + // Data type files live at datatypes/.dt — collected into + // their own bucket, never into pouFiles (pou-path detection + // throws for paths outside the pou folders). + const dataTypeFiles = await readDirRecursive(join(projectPath, 'datatypes'), 'datatypes', ['.dt']) + // Library projects own a `library.json` at the project root. // Read it as a plain string (parsing happens upstream in the // build pipeline + manifest editor — same convention POUs use: @@ -310,6 +317,7 @@ class ProjectService { pouFiles, serverFiles, remoteDeviceFiles, + dataTypeFiles, }, } } catch (error) { @@ -460,7 +468,7 @@ class ProjectService { // arrays are empty (e.g. fresh library with no servers yet), // so callers can rely on them existing. await Promise.all( - ['pous/programs', 'pous/functions', 'pous/function-blocks', 'devices/servers', 'devices/remote'].map((d) => + ['pous/programs', 'pous/functions', 'pous/function-blocks', 'devices/servers', 'devices/remote', 'datatypes'].map((d) => promises.mkdir(join(dir, d), { recursive: true }), ), ) diff --git a/src/backend/shared/project/__tests__/iterate-write-project-files.test.ts b/src/backend/shared/project/__tests__/iterate-write-project-files.test.ts index b3ffd025a..e27b92e01 100644 --- a/src/backend/shared/project/__tests__/iterate-write-project-files.test.ts +++ b/src/backend/shared/project/__tests__/iterate-write-project-files.test.ts @@ -18,6 +18,7 @@ const baseFiles: WriteProjectFiles = { pouFiles: [], serverFiles: [], remoteDeviceFiles: [], + dataTypeFiles: [], deletions: [], } @@ -126,6 +127,24 @@ describe('iterateWriteProjectFiles', () => { { category: 'remote-device', relativePath: 'devices/remote/bus0.json', content: '{"id":"bus0"}' }, ]) }) + + it('yields each data type file preserving relativePath verbatim', () => { + const entries = collect({ + ...baseFiles, + dataTypeFiles: [ + { relativePath: 'datatypes/Motor.dt', content: 'TYPE\n Motor : STRUCT\n END_STRUCT;\nEND_TYPE\n' }, + { relativePath: 'datatypes/Color.dt', content: 'TYPE\n Color : (Red);\nEND_TYPE\n' }, + ], + }) + expect(entries.filter((e) => e.category === 'data-type')).toEqual([ + { + category: 'data-type', + relativePath: 'datatypes/Motor.dt', + content: 'TYPE\n Motor : STRUCT\n END_STRUCT;\nEND_TYPE\n', + }, + { category: 'data-type', relativePath: 'datatypes/Color.dt', content: 'TYPE\n Color : (Red);\nEND_TYPE\n' }, + ]) + }) }) describe('whole-project shapes', () => { @@ -158,7 +177,7 @@ describe('iterateWriteProjectFiles', () => { }) describe('ordering', () => { - it('emits in a stable order: project.json, device-config, pin-mapping, library-manifest, pous, servers, remote-devices', () => { + it('emits in a stable order: project.json, device-config, pin-mapping, library-manifest, pous, servers, remote-devices, data-types', () => { const entries = collect({ ...baseFiles, deviceConfig: 'D', @@ -167,6 +186,7 @@ describe('iterateWriteProjectFiles', () => { pouFiles: [{ relativePath: 'pous/programs/a.st', content: 'A' }], serverFiles: [{ relativePath: 'devices/servers/s.json', content: 'S' }], remoteDeviceFiles: [{ relativePath: 'devices/remote/r.json', content: 'R' }], + dataTypeFiles: [{ relativePath: 'datatypes/T.dt', content: 'T' }], }) expect(entries.map((e) => e.category)).toEqual([ 'project-json', @@ -176,6 +196,7 @@ describe('iterateWriteProjectFiles', () => { 'pou', 'server', 'remote-device', + 'data-type', ]) }) }) diff --git a/src/backend/shared/project/iterate-write-project-files.ts b/src/backend/shared/project/iterate-write-project-files.ts index 8c698a058..6957c592c 100644 --- a/src/backend/shared/project/iterate-write-project-files.ts +++ b/src/backend/shared/project/iterate-write-project-files.ts @@ -35,6 +35,7 @@ export type WriteProjectFileEntry = | { category: 'pou'; relativePath: string; content: string } | { category: 'server'; relativePath: string; content: string } | { category: 'remote-device'; relativePath: string; content: string } + | { category: 'data-type'; relativePath: string; content: string } /** * Yield every file in a WriteProjectFiles as a `WriteProjectFileEntry`. @@ -67,4 +68,7 @@ export function* iterateWriteProjectFiles(files: WriteProjectFiles): Generator const PLCProjectDataSchema = z.object({ - dataTypes: z.array(PLCDataTypeSchema), + // Defaulted: once data types live in datatypes/.dt files the + // field disappears from newly-written project.json (DOPE-385); + // legacy projects still carry it and keep validating. + dataTypes: z.array(PLCDataTypeSchema).default([]), pous: z.array(PLCPouSchema).default([]), configuration: PLCConfigurationSchema, servers: z.array(PLCServerSchema).optional(), diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 6e852ddf0..3a94737ef 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -416,6 +416,8 @@ export async function executeSaveProject( pouFiles, serverFiles, remoteDeviceFiles, + // Populated when the .dt write path is switched on (DOPE-533). + dataTypeFiles: [], deletions: [...pendingDeletions], } diff --git a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts index 26f79c4d3..2010918ed 100644 --- a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts +++ b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts @@ -124,6 +124,23 @@ describe('serializeDataTypesToST', () => { ) }) + it('collapses newlines in structure field documentation', () => { + // A multi-line comment would desync serializeDataTypesToLines' + // line map and be unreadable by the .dt parser. + const dt: PLCDataType = { + name: 'Motor', + derivation: 'structure', + variable: [ + { + name: 'speed', + type: { definition: 'base-type', value: 'INT' }, + documentation: 'target speed\nin rpm', + }, + ], + } + expect(serializeDataTypesToST([dt])).toContain(' speed : INT; (* target speed in rpm *)\n') + }) + it('renders structure field documentation as a trailing comment', () => { const dt: PLCDataType = { name: 'Motor', diff --git a/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts b/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts index 519b47ba7..06a3cae01 100644 --- a/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts +++ b/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts @@ -180,6 +180,11 @@ describe('parseDataTypeFromText errors', () => { expect(parseDataTypeFromText('TYPE\n Color : (Red, 2bad);\nEND_TYPE\n').error).toMatch(/invalid enumeration value/) }) + it('rejects invalid structure field names', () => { + const badName = 'TYPE\n P : STRUCT\n 2bad : INT;\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(badName).error).toMatch(/invalid structure field name: "2bad"/) + }) + it('rejects unrecognized declarations with a hint', () => { expect(parseDataTypeFromText('TYPE\n Foo\nEND_TYPE\n').error).toMatch(/missing semicolon/) expect(parseDataTypeFromText('TYPE\n Foo Bar;\nEND_TYPE\n').error).toMatch(/missing colon/) diff --git a/src/frontend/utils/PLC/data-type-serializer.ts b/src/frontend/utils/PLC/data-type-serializer.ts index 016ce3f1e..26b7c1170 100644 --- a/src/frontend/utils/PLC/data-type-serializer.ts +++ b/src/frontend/utils/PLC/data-type-serializer.ts @@ -53,7 +53,9 @@ function renderEnumeratedLines(dt: Extract): string[] { const fieldLines = dt.variable.map((v) => { const init = v.initialValue?.simpleValue?.value ? ` := ${v.initialValue.simpleValue.value}` : '' - const doc = v.documentation ? ` (* ${v.documentation} *)` : '' + // Newlines collapsed: a multi-line comment would desync the + // line map (goto-definition) and be unreadable by the parser. + const doc = v.documentation ? ` (* ${v.documentation.replace(/\s*\r?\n\s*/g, ' ')} *)` : '' return ` ${v.name} : ${renderVariableType(v.type)}${init};${doc}` }) return [` ${dt.name} : STRUCT`, ...fieldLines, ` END_STRUCT;`] diff --git a/src/frontend/utils/PLC/data-type-text-parser.ts b/src/frontend/utils/PLC/data-type-text-parser.ts index 558954b4f..157f14c02 100644 --- a/src/frontend/utils/PLC/data-type-text-parser.ts +++ b/src/frontend/utils/PLC/data-type-text-parser.ts @@ -70,6 +70,9 @@ function parseStructure(name: string, body: string[]): ParseDataTypeResult { if (groups?.name === undefined || type === null) { return { error: `invalid structure field: "${line}". Possible cause: ${guessErrorReason(line)}` } } + if (!identifierRegex.test(groups.name)) { + return { error: `invalid structure field name: "${groups.name}"` } + } variable.push({ name: groups.name, type, diff --git a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts index 58e54eebe..ee78d8f25 100644 --- a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts @@ -97,6 +97,7 @@ const mockRawProjectFiles = { ], serverFiles: [], remoteDeviceFiles: [], + dataTypeFiles: [], }, } @@ -367,6 +368,7 @@ describe('createEditorProjectAdapter', () => { pouFiles: [{ relativePath: 'pous/programs/main.st', content: 'PROGRAM main\nEND_PROGRAM' }], serverFiles: [], remoteDeviceFiles: [], + dataTypeFiles: [], deletions: [], } diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index b52eecca3..9ff762761 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -122,6 +122,10 @@ export interface WriteProjectFiles { serverFiles: RawProjectFile[] /** Remote device config files with pre-serialized JSON content */ remoteDeviceFiles: RawProjectFile[] + /** Data type files (`datatypes/.dt`) with pre-serialized ST + * `TYPE…END_TYPE` content, one declaration per file. Empty until + * the `.dt` write path is switched on (DOPE-533). */ + dataTypeFiles: RawProjectFile[] /** Relative paths to delete from disk (e.g. 'pous/programs/OldPou.st') */ deletions: string[] } @@ -172,6 +176,9 @@ export interface RawProjectFiles { serverFiles: RawProjectFile[] /** Raw remote device config files from devices/remote/ */ remoteDeviceFiles: RawProjectFile[] + /** Raw data type files from datatypes/ (`.dt`, one ST `TYPE…END_TYPE` + * declaration each). Empty on projects that predate the format. */ + dataTypeFiles: RawProjectFile[] /** See {@link ProjectResponse.data.canEdit}. Carried through the * raw layer so adapters that build `ProjectResponse` from a raw * fetch don't have to round-trip the details endpoint twice. */ From e906ce6801ca57d1aa4920b8f09e2031d5588895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 17:00:48 -0300 Subject: [PATCH 33/79] style: format project-service after datatypes plumbing Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019f6Kfu7zebQC3UdeN29tiq --- src/backend/editor/services/project-service/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/backend/editor/services/project-service/index.ts b/src/backend/editor/services/project-service/index.ts index dfda0b932..38c53ade0 100644 --- a/src/backend/editor/services/project-service/index.ts +++ b/src/backend/editor/services/project-service/index.ts @@ -468,9 +468,14 @@ class ProjectService { // arrays are empty (e.g. fresh library with no servers yet), // so callers can rely on them existing. await Promise.all( - ['pous/programs', 'pous/functions', 'pous/function-blocks', 'devices/servers', 'devices/remote', 'datatypes'].map((d) => - promises.mkdir(join(dir, d), { recursive: true }), - ), + [ + 'pous/programs', + 'pous/functions', + 'pous/function-blocks', + 'devices/servers', + 'devices/remote', + 'datatypes', + ].map((d) => promises.mkdir(join(dir, d), { recursive: true })), ) // Single source of truth for the file shape lives in the From 87001fd156a246fad45ba897b069d327e6e67540 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 5 Aug 2026 16:46:15 -0400 Subject: [PATCH 34/79] fix(runtime): address review findings on the device-connection PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight defects found reviewing this branch, plus the tests that pin each. Connection lifecycle - Connect could wedge the UI permanently. The renderer sets 'connecting' optimistically and only the main process clears it, but two paths return before `deviceSession.open()` and so publish nothing: a cancelled DHCP address prompt, and a candidate list that built no usable transport. The button is disabled while 'connecting' and Disconnect only fires when 'connected', so the user had to reopen the project. Both early returns now emit 'disconnected', and the hook settles any non-success outcome in a `finally` (not on success — the status push and the invoke reply travel separate IPC channels, so settling there would risk a flicker). - A Runtime v3/v4 debug channel never closed. `requireDebug` registered every per-command caller as a lifetime holder, and `read variables` runs on every poll tick, so `releaseDebugChannel('debug session')` always found the set non-empty. Stopping the debugger left an authenticated channel open to the PLC until logout. Per-command callers now go through `withDebugChannel`, which releases in a `finally`; `debug session` stays the only lifetime holder. Safe for baremetal by construction: `releaseDebugChannel` returns early on a shared channel before touching any client. Firmware / editor agreement - `DEBUG_SLAVE` was never emitted, so the firmware fell back to modbus_config.h's `1` while the editor addressed the RTU screen's slave id (spec `params` are read regardless of `enabledWhen`). Every frame was dropped at the id check and reported as "No Firmware Detected" on a healthy board — the same defect class as the DEBUG_BAUD fix, but with no baud sweep to recover it. Added `resolveDebugSlave` and emitted `#define DEBUG_SLAVE`. Run/stop - Runtime v3 lost Start/Stop silently. v3 exposes the same JWT-authenticated `/api/start-plc` and `/api/stop-plc` as v4 (webserver/restapi.py routes under `url_prefix='/api'`); only the debug channel differs. The main process already routes the command over REST for both, so `plcStateControl: false` was the only thing stopping it. Also folded the capability into `plcControlBlocked`, so a target that genuinely lacks run/stop shows a reason instead of an enabled button that does nothing. - `verifyMd5` dropped `targetMd5`: `Md5ProbeResult` names the hash `md5`, so `...probe` left the declared field undefined and a spread skips excess property checks. The mismatch report read "Target: undefined". - Corrected the run/stop function code in the capability doc (0x49 -> 0x4b). Tests - compiler-module.spec.ts asserted the old `foo.o` archive names and failed on this branch; updated to `foo.cpp.o`. - Restored coverage on every file this branch touches under a 100% threshold: device-adapter (100 -> 62.5%), debugger-adapter, the new device-connect-events, plus previously uncovered `setPlcSwitchPosition`, `setDeviceConnectionStatus`'s transport arguments, the simulator client's `setPlcState`, and two resolver/parser error paths. Measured against the branch's merge base, all four threshold directories are now at or above baseline and no file lost coverage. Co-Authored-By: Claude Opus 5 (1M context) --- resources/sources/Baremetal/modbus_config.h | 8 +- .../editor/compiler/compiler-module.spec.ts | 11 +- .../device-session-channel-lifetime.test.ts | 175 ++++++++++++++++++ .../editor/hardware/device-session-manager.ts | 5 + .../__tests__/generate-defines.test.ts | 35 ++++ .../compile/__tests__/modbus-defines.test.ts | 44 ++++- .../shared/compile/steps/generate-defines.ts | 9 +- .../shared/compile/steps/modbus-defines.ts | 30 +++ .../__tests__/modbus-pdu-plc-control.test.ts | 13 ++ .../connect-resolve-regression.test.ts | 20 ++ .../__tests__/modbus-rtu-client.test.ts | 128 ++++++++++++- .../workspace-activity-bar/default.tsx | 12 +- .../__tests__/use-device-connect.test.ts | 75 +++++++- src/frontend/hooks/use-device-connect.ts | 122 +++++++----- .../store/__tests__/device-slice.test.ts | 62 +++++++ .../__tests__/device-connect-events.test.ts | 79 ++++++++ src/main/modules/ipc/main.ts | 141 ++++++++++---- .../editor/__tests__/debugger-adapter.test.ts | 27 +++ .../editor/__tests__/device-adapter.test.ts | 53 ++++++ .../utils/target-capabilities/presets.ts | 7 +- .../shared/utils/target-capabilities/types.ts | 14 +- 21 files changed, 968 insertions(+), 102 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts create mode 100644 src/frontend/utils/__tests__/device-connect-events.test.ts diff --git a/resources/sources/Baremetal/modbus_config.h b/resources/sources/Baremetal/modbus_config.h index 950869cf7..642bd5b9f 100644 --- a/resources/sources/Baremetal/modbus_config.h +++ b/resources/sources/Baremetal/modbus_config.h @@ -27,8 +27,12 @@ it must reach a TU through exactly one path: this header. #endif // Default serial config for the always-on debugger. `defines.h` normally emits -// DEBUG_IFACE / DEBUG_BAUD explicitly (from the Serial screen); these `#ifndef` -// defaults cover anything it left unset. Defined whenever the debugger is on — +// DEBUG_IFACE / DEBUG_BAUD / DEBUG_SLAVE explicitly (from the Serial and Modbus +// RTU screens); these `#ifndef` defaults cover anything it left unset — they are +// a fallback for a hand-written defines.h, NOT the expected path. When they do +// apply, they must agree with what the editor dials, so they mirror +// `generate-defines.ts` (Serial @ 115200, slave id 1). +// Defined whenever the debugger is on — // including alongside full Modbus RTU, since the dual-serial path (RTU on a // secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. #ifdef DEBUGGER_ENABLED diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 7cea25be6..005146485 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -540,9 +540,12 @@ describe('CompilerModule', () => { handleOutputData: noopLog, }) - const aPos = arCmd.indexOf('a_first.o') - const mPos = arCmd.indexOf('m_middle.o') - const zPos = arCmd.indexOf('z_last.o') + // `foo.cpp.o`, not `foo.o` — the `.cpp` is KEPT so esp8266's linker script + // matches `*.cpp.o` and sends the code to flash instead of the 32 KB IRAM + // catch-all. See the objectFiles comment in handlePrecompileUserLib. + const aPos = arCmd.indexOf('a_first.cpp.o') + const mPos = arCmd.indexOf('m_middle.cpp.o') + const zPos = arCmd.indexOf('z_last.cpp.o') expect(aPos).toBeGreaterThan(-1) expect(mPos).toBeGreaterThan(aPos) expect(zPos).toBeGreaterThan(mPos) @@ -658,7 +661,7 @@ describe('CompilerModule', () => { // The TU set discovered from the stash matches the original two // strucpp sources — order is deterministic (sorted basenames). - expect(result.objectFiles.map((p) => p.split(/[\\/]/).pop())).toEqual(['configuration.o', 'pou_MAIN.o']) + expect(result.objectFiles.map((p) => p.split(/[\\/]/).pop())).toEqual(['configuration.cpp.o', 'pou_MAIN.cpp.o']) }) it('injects -I{build.core.path} and -I{build.variant.path} into every TU compile (so Arduino.h resolves)', async () => { diff --git a/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts b/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts new file mode 100644 index 000000000..c321698c0 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts @@ -0,0 +1,175 @@ +/** + * Who may close the debug channel, and when. + * + * Two rules that pull against each other, which is why they are pinned here: + * + * - A session whose debug medium is its OWN (Runtime v3's second Modbus TCP + * connection, v4's WebSocket) must close that channel when the debug session + * ends. Leaving it open holds an authenticated channel to the user's PLC for + * no reason, and contradicts the whole point of opening it lazily. + * - A BAREMETAL session must NOT close anything: control and debug are the same + * connection, so closing on debug-stop would disconnect the device and take + * run/stop and the status poll down with it. + * + * The bug these cover: per-command callers (`read variables` on every poll tick, + * `write variable`, `verify md5`) were registered as lifetime holders, so the + * holder set was never empty and the v3/v4 channel never closed. + */ +import type { DeviceDebugChannel, DeviceModbusTransport } from '../../../shared/debug/types' +import { type DeviceLinkHooks, DeviceSessionManager } from '../device-session-manager' + +/** A debug channel that records whether it was closed. */ +function fakeDebugChannel() { + const channel = { + connect: () => Promise.resolve(), + disconnect: () => { + channel.disconnects += 1 + }, + disconnects: 0, + } + return channel +} + +/** A Modbus client standing in for a held baremetal link. */ +function fakeModbusClient() { + const client = { + connect: () => Promise.resolve(), + disconnect: () => { + client.disconnects += 1 + }, + disconnects: 0, + } + return client +} + +function managerWith(overrides: Partial = {}) { + return new DeviceSessionManager({ + verify: () => Promise.resolve(true), + probe: () => Promise.resolve(true), + serialPortPresent: () => Promise.resolve(true), + emit: () => undefined, + log: () => undefined, + ...overrides, + }) +} + +describe('debug channel lifetime — a session with its own debug medium (v3 / v4)', () => { + it('closes the channel once the debug session releases, even after per-command use', async () => { + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => channel as unknown as DeviceDebugChannel, + }, + }) + + // The real order main.ts uses: connect (the lifetime holder), then commands. + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('verify md5') + manager.releaseDebugChannel('verify md5') + for (let tick = 0; tick < 3; tick += 1) { + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + } + expect(channel.disconnects).toBe(0) // still debugging — nothing may close it + + manager.releaseDebugChannel('debug session') + + expect(channel.disconnects).toBe(1) + expect(manager.getDebugClient()).toBeNull() + }) + + it('opens exactly one channel across the whole session', async () => { + let created = 0 + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => { + created += 1 + return channel as unknown as DeviceDebugChannel + }, + }, + }) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + + expect(created).toBe(1) + }) + + it('keeps the channel while a second holder still needs it', async () => { + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => channel as unknown as DeviceDebugChannel, + }, + }) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('licensing') + manager.releaseDebugChannel('debug session') + + // Ref-counting still holds: one release does not close a channel another holds. + expect(channel.disconnects).toBe(0) + manager.releaseDebugChannel('licensing') + expect(channel.disconnects).toBe(1) + }) +}) + +describe('debug channel lifetime — a baremetal session (one shared channel)', () => { + it('never closes the device connection when a debug caller releases', async () => { + const client = fakeModbusClient() + const manager = managerWith() + const opened = await manager.open([ + { + transport: 'rtu', + descriptor: '/dev/ttyACM0', + baudRate: 115200, + create: () => client as unknown as DeviceModbusTransport, + }, + ]) + expect(opened.ok).toBe(true) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + manager.releaseDebugChannel('debug session') + + // The control channel IS the debug channel here. Stopping the debugger must + // leave run/stop and the status poll with a live connection. + expect(client.disconnects).toBe(0) + expect(manager.isConnected()).toBe(true) + expect(manager.getDebugClient()).not.toBeNull() + manager.close() + }) +}) + +describe('open() always reports a settled state', () => { + it('emits disconnected when no candidate could be built', async () => { + // Otherwise the renderer, which set 'connecting' the moment the user clicked, + // is left there forever with its Connect button disabled. + const emitted: string[] = [] + const manager = managerWith({ + emit: (status) => { + emitted.push(status.status) + }, + }) + + const result = await manager.open([]) + + expect(result.ok).toBe(false) + expect(emitted).toContain('disconnected') + }) +}) diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 5e6b90e3c..6c27f658e 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -376,6 +376,11 @@ export class DeviceSessionManager { if (candidates.length === 0) { this.trace('open: refused, no usable candidate was resolved') + // Still report a settled state. The `close({ silent: true })` above suppressed + // its own notification on the assumption that this open would publish one, so + // returning quietly here leaves the renderer showing 'connecting' forever — + // and its Connect button disabled with no way back. + this.hooks.emit({ status: 'disconnected' }) return { ok: false, attempts: [] } } diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index fc924d860..3b9dfb64f 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -251,6 +251,40 @@ describe('generateDefinesContent — Debugger block (always-on debug)', () => { expect(out).toContain('#define DEBUG_BAUD 115200') }) + it('emits DEBUG_SLAVE from the RTU screen so it matches the id the editor addresses', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600', rtu_slave_id: 3 }, + }, + }) + expect(out).toContain('#define MBSERIAL_SLAVE 3') + expect(out).toContain('#define DEBUG_SLAVE 3') + }) + + // The slave-id twin of the DEBUG_BAUD regression above, and the harsher one: + // Connect sweeps baud rates, but nothing sweeps slave ids. With Modbus off and + // slave id 7 saved on the screen, the editor addresses 7 while a firmware left + // on modbus_config.h's `#ifndef DEBUG_SLAVE 1` fallback frames on 1 — every + // frame dropped at the id check, reported as "No Firmware Detected". + it('aligns DEBUG_SLAVE with the screen slave id when Modbus is DISABLED', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { modbus_rtu: { enabled: false, rtu_slave_id: 7 } }, + }) + expect(out).toContain('#define DEBUG_SLAVE 7') + expect(out).not.toContain('#define MODBUS_ENABLED') + }) + + it('falls back to DEBUG_SLAVE 1 when the project states no slave id', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('#define DEBUG_SLAVE 1') + }) + it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'simulator' }) expect(out).not.toContain('DEBUGGER_ENABLED') @@ -471,6 +505,7 @@ describe('generateDefinesContent — full output snapshot', () => { '#define DEBUGGER_ENABLED', '#define DEBUG_IFACE Serial', '#define DEBUG_BAUD 115200', + '#define DEBUG_SLAVE 1', '', '', '//IO Config', diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index deeb036f9..918fa67ed 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -1,4 +1,10 @@ -import { DEFAULT_DEBUG_BAUD, generateModbusDefines, resolveDebugBaud } from '../steps/modbus-defines' +import { + DEFAULT_DEBUG_BAUD, + DEFAULT_DEBUG_SLAVE, + generateModbusDefines, + resolveDebugBaud, + resolveDebugSlave, +} from '../steps/modbus-defines' /** * The baud the always-on debugger answers on. It has to agree with the rate the @@ -68,6 +74,42 @@ describe('resolveDebugBaud', () => { }) }) +/** + * The slave id the always-on debugger frames on. Unlike the baud, a mismatch here + * is NOT recoverable by the connect flow's rate sweep — the firmware silently + * drops every frame whose first byte isn't this id, and that check is the only + * validation debug function codes get. So these pin exact agreement with the id + * the editor addresses (`screens.modbus_rtu.rtu_slave_id`, read regardless of + * whether the RTU is enabled). + */ +describe('resolveDebugSlave', () => { + it('uses the RTU screen slave id when the RTU is enabled', () => { + expect(resolveDebugSlave({ modbus_rtu: { enabled: true, rtu_slave_id: 3 } })).toBe(3) + }) + + it('uses the RTU screen slave id even when the RTU is DISABLED', () => { + // The regression this exists for: a TCP-only (or Modbus-off) project still + // has the editor addressing the RTU screen's id over serial, because a debug + // spec's `params` are read independently of its `enabledWhen`. Defaulting to + // 1 here made a healthy board report "No Firmware Detected". + expect(resolveDebugSlave({ modbus_rtu: { enabled: false, rtu_slave_id: 7 } })).toBe(7) + }) + + it('uses the RTU screen slave id even when the RTU runs on a secondary UART', () => { + // Not a conflict: MBSERIAL_SLAVE frames that id on Serial1 while DEBUG_SLAVE + // frames it on Serial. Two distinct ports, and the editor still dials this id. + expect(resolveDebugSlave({ modbus_rtu: { enabled: true, serial_port: 'Serial1', rtu_slave_id: 4 } })).toBe(4) + }) + + it('falls back when the RTU section states no slave id', () => { + expect(resolveDebugSlave({ modbus_rtu: { enabled: true } })).toBe(DEFAULT_DEBUG_SLAVE) + }) + + it('falls back for an empty project', () => { + expect(resolveDebugSlave({})).toBe(DEFAULT_DEBUG_SLAVE) + }) +}) + describe('generateModbusDefines', () => { it('returns an empty string when neither RTU nor TCP is enabled', () => { expect(generateModbusDefines({})).toBe('') diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index dc62c0899..adc10c136 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -18,7 +18,7 @@ */ import type { DevicePin } from '../../types/PLC/devices' -import { generateModbusDefines, resolveDebugBaud, type VppModbusScreenState } from './modbus-defines' +import { generateModbusDefines, resolveDebugBaud, resolveDebugSlave, type VppModbusScreenState } from './modbus-defines' export type { VppModbusScreenState } from './modbus-defines' @@ -192,6 +192,13 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { // firmware listening at 115200 while the editor dialled the RTU's baud, and // the board answered nothing ("No Firmware Detected" on a healthy board). DEFINES_CONTENT += `#define DEBUG_BAUD ${resolveDebugBaud(vppModbusState ?? {}, defaultSerial)}\n` + // Same two-sided agreement as the baud, and the same symptom when it breaks: + // the firmware drops every frame whose slave id doesn't match, and that check + // is the only validation debug function codes get. The editor addresses the + // RTU screen's slave id whether or not the RTU is enabled, so emit it rather + // than leaving modbus_config.h's `#ifndef DEBUG_SLAVE 1` fallback to disagree + // with a project that configured anything else. + DEFINES_CONTENT += `#define DEBUG_SLAVE ${resolveDebugSlave(vppModbusState ?? {})}\n` DEFINES_CONTENT += `\n\n` } diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 11e657f3d..7921c5022 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -119,6 +119,36 @@ export function resolveDebugBaud(state: VppModbusScreenState, defaultSerial: str return rtu.baud_rate ?? rtu.rtu_baud_rate ?? DEFAULT_DEBUG_BAUD } +/** Slave id the always-on debugger frames on when the project states none. */ +export const DEFAULT_DEBUG_SLAVE = 1 + +/** + * Modbus slave id the always-on debugger answers on — and therefore the id the + * editor must address to reach it. + * + * The same two-sided agreement `resolveDebugBaud` describes, and the same failure + * when it breaks: `handle_serial_port` drops any frame whose first byte is not + * this id, and that check is the ONLY validation applied to debug function codes + * (CRC is skipped on them). A mismatch is therefore total silence on a healthy + * board — reported as "No Firmware Detected". + * + * What the editor addresses is `screens.modbus_rtu.rtu_slave_id`, ALWAYS: a + * spec's `params` are read independently of its `enabledWhen`, so an RTU screen + * left at slave id 7 with the RTU toggle OFF still sends id 7 down the cable. + * So this returns that id unconditionally — including when the RTU runs on a + * SECOND UART, where it is not a conflict but the same number on two distinct + * ports. + * + * Deliberately NOT the `resolveDebugBaud` shape of "guess 115200 for a secondary + * port": a wrong baud is recoverable, because Connect sweeps the plausible rates. + * There is no sweep for slave ids, so this has to match exactly rather than + * approximately. + */ +export function resolveDebugSlave(state: VppModbusScreenState): number { + const slave = state.modbus_rtu?.rtu_slave_id + return typeof slave === 'number' ? slave : DEFAULT_DEBUG_SLAVE +} + /** * `aa:bb:cc:dd:ee:ff` → `0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff` so it can * land verbatim in `byte mac[] = { MBTCP_MAC };`. Accepts the canonical diff --git a/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts index 23f3adbb5..79772b12d 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts @@ -80,6 +80,19 @@ describe('parsePlcSetStateResponse', () => { expect(result.state).toBe(PlcRuntimeState.ERROR) }) + it('describes any other failure status rather than failing silently', () => { + // Neither SUCCESS nor REFUSED_BY_SWITCH — e.g. the runtime ran out of memory + // servicing the request. Without the error text the editor would report + // "Failed to start PLC: Unknown error" and give the user nothing to act on. + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.ERROR_OUT_OF_MEMORY, PlcRuntimeState.STOPPED, PlcSwitchPosition.RUN), + ) + expect(result.success).toBe(false) + expect(result.refusedBySwitch).toBeUndefined() + expect(result.unsupported).toBeUndefined() + expect(result.error).toBeTruthy() + }) + it('detects old firmware via the Modbus exception form', () => { // A runtime built before the state machine answers (FC | 0x80). The editor turns this // into "rebuild and upload", never an error, so field devices don't look diff --git a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts index e2c285995..20f47ab24 100644 --- a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts +++ b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts @@ -328,6 +328,26 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { ) }) + it('reports an error when no declared channel matches a transport the target speaks', () => { + // A target whose capability matrix says `['websocket']` cannot use a spec that + // only declares serial and TCP — nothing is eligible. Reported as an error, not + // silently as an empty candidate list, because an empty list downstream reads as + // "connected to nothing" and every later command then times out unexplained. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: ['websocket'] }) + + expect(result.kind).toBe('error') + if (result.kind === 'error') expect(result.body).toBeTruthy() + }) + + it('prefers the spec-supplied noneEnabled message when it has one', () => { + const withMessage: DebugSpec = { + ...bothChannelsSpec, + messages: { noneEnabled: { title: 'Nope', body: 'This board needs an ethernet shield.' } }, + } + const result = resolveDeviceLinkCandidates(withMessage, tcpOnlyContext(), { transports: ['websocket'] }) + expect(result).toMatchObject({ kind: 'error', title: 'Nope', body: 'This board needs an ethernet shield.' }) + }) + it('shows why a precondition cannot express a debugger-only requirement', () => { // Adding ANY precondition to the spec above breaks Connect, because Connect // resolves this same spec with nothing connected. diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 15a85d885..a64336794 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -7,7 +7,7 @@ */ import { ModbusRtuClient, type SerialPortLike } from '../modbus-rtu-client' -import { ModbusDebugResponse, ModbusFunctionCode } from '../types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState, PlcSwitchPosition } from '../types' // jsdom polyfill if (typeof globalThis.TextEncoder === 'undefined') { @@ -936,6 +936,26 @@ describe('ModbusRtuClient', () => { expect(result.error).toContain('too short') }) + it('setPlcState handles response too short (<8 bytes)', async () => { + // Belongs here rather than with the wire-framing tests: `sendRequest` prepends + // 6 bytes of TCP-compat padding, so no real reply can be short enough to reach + // this guard — a 4-byte frame lands on the PARSER's own "too short" instead. + // Both messages read alike, which is how a fixture can appear to cover this + // branch while never entering it. + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0])) + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid response: too short') + }) + + it('setPlcState reports a non-Error rejection', async () => { + await connectClient() + mockSendRequest(client, 'serial port vanished') + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(result).toEqual({ success: false, error: 'serial port vanished' }) + }) + it('getBoardId handles non-Error exception', async () => { await connectClient() mockSendRequest(client, 'non-error string') @@ -1033,4 +1053,110 @@ describe('ModbusRtuClient', () => { expect(result.success).toBe(true) }) }) + + // ----------------------------------------------------------------------- + // setPlcState (FC 0x4b) + // + // The end-to-end coverage for run/stop lives in plc-control-e2e.test.ts, which + // only runs when it is pointed at built firmware — so without these the wire + // framing here is unexercised on any ordinary test run. + // ----------------------------------------------------------------------- + describe('setPlcState', () => { + /** `[status][plcState][switchPosition]` — the FC 0x4b acknowledgement payload. */ + function ackPayload(status: number, state: number, switchPosition: number): Uint8Array { + return new Uint8Array([status, state, switchPosition]) + } + + it('sends the run request and returns the acknowledged state', async () => { + await connectClient() + const written: number[][] = [] + port._interceptWrite = (data: Uint8Array) => { + written.push(Array.from(data)) + setTimeout( + () => + port._emit( + 'data', + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.SUCCESS, PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN), + ), + ), + 0, + ) + } + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + // Request is [slaveId][FC][state] + CRC — the state byte is what distinguishes + // run from stop, and getting it backwards would stop a PLC on a start click. + expect(written[0][1]).toBe(ModbusFunctionCode.PLC_SET_STATE) + expect(written[0][2]).toBe(1) + expect(result).toMatchObject({ success: true, state: PlcRuntimeState.RUNNING }) + }) + + it('encodes a stop request as state 0', async () => { + await connectClient() + const written: number[][] = [] + port._interceptWrite = (data: Uint8Array) => { + written.push(Array.from(data)) + setTimeout( + () => + port._emit( + 'data', + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.SUCCESS, PlcRuntimeState.STOPPED, PlcSwitchPosition.RUN), + ), + ), + 0, + ) + } + + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + + expect(written[0][2]).toBe(0) + expect(result).toMatchObject({ success: true, state: PlcRuntimeState.STOPPED }) + }) + + it('surfaces a RUN refused by the hardware mode switch', async () => { + await connectClient() + autoRespond( + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.REFUSED_BY_SWITCH, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ), + ) + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + // Drives the "flip the switch to RUN" warning rather than a generic failure. + expect(result).toMatchObject({ success: false, refusedBySwitch: true }) + }) + + it('rejects a well-framed reply whose PDU is truncated', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.PLC_SET_STATE]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) }) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index ca57c6bc3..63645144f 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -99,8 +99,16 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Deliberately NOT applied to Build & Upload. Uploading is how a blank board stops // being blank, so it cannot require a connection — see `handleBuild`, where the // connection is consulted only to hand the serial port over to arduino-cli. - const plcControlBlocked = deviceConnectionStatus !== 'connected' - const plcControlBlockedReason = 'Connect to the target first' + // + // A target that does not implement run/stop at all is blocked for a DIFFERENT + // reason, and says so. `handlePlcControl` refuses such a target anyway, so + // without this the button looked live and the click did nothing at all — no + // command, no error, no log line. + const plcStateControlSupported = resolveTargetCapabilities(currentBoardInfo).plcStateControl + const plcControlBlocked = !plcStateControlSupported || deviceConnectionStatus !== 'connected' + const plcControlBlockedReason = plcStateControlSupported + ? 'Connect to the target first' + : 'This target does not support Start/Stop from the editor' // The emulator stopping is a session ending, and a debug session riding it ends // with it — which the drop handler below already does for every target. This diff --git a/src/frontend/hooks/__tests__/use-device-connect.test.ts b/src/frontend/hooks/__tests__/use-device-connect.test.ts index 33a5e2ef9..f72b09517 100644 --- a/src/frontend/hooks/__tests__/use-device-connect.test.ts +++ b/src/frontend/hooks/__tests__/use-device-connect.test.ts @@ -2,9 +2,20 @@ import { renderHook } from '@testing-library/react' // `mock*`-prefixed refs are hoisted into the jest.mock factories below. const mockOpenModal = jest.fn() -const mockSetDeviceConnectionStatus = jest.fn() const mockAddLog = jest.fn() +/** + * Writes through to `mockState`, like the real action does. The hook reads the + * live status back to decide whether a settled state still needs publishing, so a + * write-only spy would make that branch untestable. + */ +const mockSetDeviceConnectionStatus = jest.fn((status: string, port: string | null = null) => { + mockState.deviceConnection = { status, port } +}) + +/** The status the store ended up in — what the Connect button actually reads. */ +const currentStatus = (): string => (mockState.deviceConnection as { status: string }).status + const mockState: Record = { deviceDefinitions: { configuration: { deviceBoard: 'Test Board', communicationPort: 'COM5', vendorScreenData: {} } }, deviceConnection: { status: 'disconnected', port: null }, @@ -206,4 +217,66 @@ describe('useDeviceConnect', () => { expect(result.current.isConnecting).toBe(false) expect(result.current.status).toBe('connected') }) + + /** + * The Connect button is disabled while the status reads 'connecting', and + * Disconnect only fires when it reads 'connected'. So a status left at + * 'connecting' is a dead button with no way back short of reopening the project. + * Every path out of `connect()` must therefore leave a settled status — including + * the ones that never reach the main process, which is where the wedge was. + */ + describe('never leaves the button stuck on "connecting"', () => { + it('settles when the user cancels the address prompt and nothing else was tried', async () => { + // A DHCP-only target: no silent candidate at all, one channel awaiting input. + // Cancelling the prompt used to leave 'connecting' set forever, because + // device.connect() was never called and so nothing ever pushed a status. + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [0] })) + .mockImplementationOnce(() => Promise.resolve(null)) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).not.toHaveBeenCalled() + expect(currentStatus()).toBe('disconnected') + // Nothing was attempted, so there is no failure to report either. + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('settles when the prompted pass resolves no usable candidate', async () => { + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [0] })) + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [] })) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(currentStatus()).toBe('disconnected') + }) + + it('settles after a failure dialog', async () => { + mockConnect.mockResolvedValue({ status: 'no-response' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Response' }) + expect(currentStatus()).toBe('disconnected') + }) + + it('settles when the connect IPC call rejects outright', async () => { + mockConnect.mockRejectedValue(new Error('bridge is gone')) + const { result } = renderHook(() => useDeviceConnect(board)) + await expect(result.current.connect()).rejects.toThrow('bridge is gone') + expect(currentStatus()).toBe('disconnected') + }) + + it('leaves a successful connection alone for the main process to publish', async () => { + // The status push and this reply travel separate IPC channels, so settling on + // success too would risk overwriting 'connected' with a spurious flicker. + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(currentStatus()).toBe('connecting') + expect(mockSetDeviceConnectionStatus).not.toHaveBeenCalledWith('disconnected', null) + }) + }) }) diff --git a/src/frontend/hooks/use-device-connect.ts b/src/frontend/hooks/use-device-connect.ts index 25c9cf53a..f6ba9e5af 100644 --- a/src/frontend/hooks/use-device-connect.ts +++ b/src/frontend/hooks/use-device-connect.ts @@ -54,61 +54,91 @@ export function useDeviceConnect(boardInfo: BoardInfo | undefined): UseDeviceCon const tried: string[] = [] setDeviceConnectionStatus('connecting', null) + // Declared out here so the `finally` can tell "we never got a connection" from + // "we did, and the main process has already published it". let result: { status: 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error'; error?: string } = { status: 'no-response', } - if (silent.candidates.length > 0) { - tried.push(...silent.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) - result = await device.connect(silent.candidates.map((candidate) => candidate.config)) - } - // SECOND PASS: nothing silent worked, so now it is worth asking. Resolving - // only the deferred channels surfaces the address dialog, and a cancel here - // ends the attempt rather than looping. - if (result.status !== 'connected-with-firmware' && silent.awaitingInput.length > 0) { - const prompted = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { - onlyChannels: silent.awaitingInput, - }) - if (prompted && prompted.candidates.length > 0) { - tried.push(...prompted.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) - result = await device.connect(prompted.candidates.map((candidate) => candidate.config)) + try { + if (silent.candidates.length > 0) { + tried.push(...silent.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(silent.candidates.map((candidate) => candidate.config)) } - } - const endpoints = tried.join(' or ') || 'this device' + // SECOND PASS: nothing silent worked, so now it is worth asking. Resolving + // only the deferred channels surfaces the address dialog, and a cancel here + // ends the attempt rather than looping. + if (result.status !== 'connected-with-firmware' && silent.awaitingInput.length > 0) { + const prompted = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { + onlyChannels: silent.awaitingInput, + }) + if (prompted && prompted.candidates.length > 0) { + tried.push(...prompted.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(prompted.candidates.map((candidate) => candidate.config)) + } else if (tried.length === 0) { + // The user declined to supply the address and there was nothing else to + // try, so nothing was attempted at all. Saying "could not reach the + // device" would be reporting a failure that never happened — they + // cancelled. The `finally` below clears the button. + return + } + } - if (result.status === 'no-response') { - openModal('debugger-message', { - type: 'error', - title: 'No Response', - message: `Could not reach the device on ${endpoints}. Check that it is powered and plugged in, and that the port or IP address is correct.`, - buttons: ['OK'], - onResponse: () => undefined, - }) - return - } + const endpoints = tried.join(' or ') || 'this device' - if (result.status === 'error') { - openModal('debugger-message', { - type: 'error', - title: 'Connection Error', - message: result.error ?? 'An unexpected error occurred while connecting to the device.', - buttons: ['OK'], - onResponse: () => undefined, - }) - return - } + if (result.status === 'no-response') { + openModal('debugger-message', { + type: 'error', + title: 'No Response', + message: `Could not reach the device on ${endpoints}. Check that it is powered and plugged in, and that the port or IP address is correct.`, + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } + + if (result.status === 'error') { + openModal('debugger-message', { + type: 'error', + title: 'Connection Error', + message: result.error ?? 'An unexpected error occurred while connecting to the device.', + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } - if (result.status === 'no-firmware') { - openModal('debugger-message', { - type: 'question', - title: 'No Firmware Detected', - message: `No OpenPLC firmware responded on ${endpoints}. Build & Upload the program to flash this device, then Connect again.`, - buttons: ['Build & Upload', 'Cancel'], - onResponse: (buttonIndex: number) => { - if (buttonIndex === 0) requestDeviceFlash() - }, - }) + if (result.status === 'no-firmware') { + openModal('debugger-message', { + type: 'question', + title: 'No Firmware Detected', + message: `No OpenPLC firmware responded on ${endpoints}. Build & Upload the program to flash this device, then Connect again.`, + buttons: ['Build & Upload', 'Cancel'], + onResponse: (buttonIndex: number) => { + if (buttonIndex === 0) requestDeviceFlash() + }, + }) + } + } finally { + // 'connecting' is set OPTIMISTICALLY above, and normally only the main process + // clears it — every settled state is pushed from there. But a path that + // returns without ever reaching `deviceSession.open()` leaves nothing to push: + // a cancelled address prompt, a config that built no usable candidate, or an + // IPC rejection. The button is disabled while 'connecting' and Disconnect only + // fires when 'connected', so a stuck 'connecting' is not recoverable from the + // UI at all — the user has to close and reopen the project. Settle it here. + // + // Only on a NON-success outcome. On success the main process has published + // 'connected', but that push and this invoke's reply travel separate IPC + // channels with no ordering guarantee between them, so settling here as well + // would risk a visible flicker for no reason. + if ( + result.status !== 'connected-with-firmware' && + useOpenPLCStore.getState().deviceConnection.status === 'connecting' + ) { + setDeviceConnectionStatus('disconnected', null) + } } }, [boardInfo, device, openModal, setDeviceConnectionStatus]) diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 92ffed027..cbc0b7843 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -206,6 +206,41 @@ describe('createDeviceSlice', () => { }) }) + it('setDeviceConnectionStatus records both media when the manager reports them', () => { + // What `useDeviceConnectionMonitor` actually forwards. `debugTransport` is a + // separate fact from `transport`: the debug poll sizes its batches to the + // debug medium, and a v4 session (control over REST, debug over a WebSocket) + // has no control transport at all. + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '192.168.0.9', null, 'websocket') + expect(store.getState().deviceConnection).toEqual({ + status: 'connected', + port: '192.168.0.9', + transport: null, + debugTransport: 'websocket', + }) + }) + + it('setDeviceConnectionStatus records a shared medium on both slots', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '/dev/ttyACM0', 'rtu', 'rtu') + expect(store.getState().deviceConnection).toMatchObject({ transport: 'rtu', debugTransport: 'rtu' }) + }) + + it('setDeviceConnectionStatus leaves the media untouched when they are omitted', () => { + // A status-only update (the optimistic 'connecting') must not wipe what the + // manager last reported. + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '/dev/ttyACM0', 'rtu', 'rtu') + store.getState().deviceActions.setDeviceConnectionStatus('connecting') + expect(store.getState().deviceConnection).toEqual({ + status: 'connecting', + port: '/dev/ttyACM0', + transport: 'rtu', + debugTransport: 'rtu', + }) + }) + it('clearDeviceConnection resets to disconnected/null', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') @@ -1329,6 +1364,33 @@ describe('createDeviceSlice', () => { }) }) + // ----------------------------------------------------------------------- + // setPlcSwitchPosition + // ----------------------------------------------------------------------- + describe('setPlcSwitchPosition', () => { + it('defaults to null — unknown, not "no gating"', () => { + // The start pre-check must be able to tell "the switch says RUN" from "this + // target has no switch / firmware too old to report one". Only 'stop' blocks + // a start; null must not, or a board without a switch is un-startable. + expect(makeStore().getState().runtimeConnection.switchPosition).toBeNull() + }) + + it('records the switch reading', () => { + const store = makeStore() + store.getState().deviceActions.setPlcSwitchPosition('stop') + expect(store.getState().runtimeConnection.switchPosition).toBe('stop') + store.getState().deviceActions.setPlcSwitchPosition('run') + expect(store.getState().runtimeConnection.switchPosition).toBe('run') + }) + + it('clears back to null on disconnect', () => { + const store = makeStore() + store.getState().deviceActions.setPlcSwitchPosition('stop') + store.getState().deviceActions.setPlcSwitchPosition(null) + expect(store.getState().runtimeConnection.switchPosition).toBeNull() + }) + }) + // ----------------------------------------------------------------------- // setSelectedDevice // ----------------------------------------------------------------------- diff --git a/src/frontend/utils/__tests__/device-connect-events.test.ts b/src/frontend/utils/__tests__/device-connect-events.test.ts new file mode 100644 index 000000000..c3f9f17c3 --- /dev/null +++ b/src/frontend/utils/__tests__/device-connect-events.test.ts @@ -0,0 +1,79 @@ +/** + * The decoupled bridge between the device screen's "No Firmware Detected" dialog + * and Build & Upload, which live in different component trees. + * + * Worth pinning despite being three lines: the event NAME is the contract between + * the two sides, and a typo on either would be silent — the dialog's "Build & + * Upload" button would simply do nothing, with no error to trace. These tests only + * ever go through the public functions, so they cannot drift from that name. + */ +import { onDeviceFlashRequest, requestDeviceFlash } from '../device-connect-events' + +describe('device flash-request bridge', () => { + it('delivers a request to a subscriber', () => { + const handler = jest.fn() + const unsubscribe = onDeviceFlashRequest(handler) + + requestDeviceFlash() + + expect(handler).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('delivers synchronously, so the dialog handler can rely on it having run', () => { + const order: string[] = [] + const unsubscribe = onDeviceFlashRequest(() => order.push('handler')) + + requestDeviceFlash() + order.push('after dispatch') + + expect(order).toEqual(['handler', 'after dispatch']) + unsubscribe() + }) + + it('delivers to every subscriber', () => { + const first = jest.fn() + const second = jest.fn() + const unsubFirst = onDeviceFlashRequest(first) + const unsubSecond = onDeviceFlashRequest(second) + + requestDeviceFlash() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + unsubFirst() + unsubSecond() + }) + + it('stops delivering once unsubscribed', () => { + // The returned function is used as a React effect cleanup, so a listener that + // survived it would fire once per remount — the user pressing "Build & Upload" + // once would trigger several builds. + const handler = jest.fn() + const unsubscribe = onDeviceFlashRequest(handler) + + unsubscribe() + requestDeviceFlash() + + expect(handler).not.toHaveBeenCalled() + }) + + it('unsubscribing one subscriber leaves the others listening', () => { + const kept = jest.fn() + const dropped = jest.fn() + const unsubKept = onDeviceFlashRequest(kept) + const unsubDropped = onDeviceFlashRequest(dropped) + + unsubDropped() + requestDeviceFlash() + + expect(kept).toHaveBeenCalledTimes(1) + expect(dropped).not.toHaveBeenCalled() + unsubKept() + }) + + it('is safe to call with nobody listening', () => { + // The device screen can be closed between the dialog opening and the response. + expect(() => requestDeviceFlash()).not.toThrow() + }) +}) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 09a3ef041..a0d775095 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -90,6 +90,19 @@ function matchesMd5(targetMd5: string, expectedMd5: string): boolean { return targetMd5.toLowerCase() === expectedMd5.toLowerCase() } +/** + * What `debugger:verify-md5` answers. Named so the success and unavailable paths + * are typed against ONE shape — inferred separately, the success branch narrowed + * `success` to the literal `true` and the two stopped being assignable. + */ +interface Md5VerifyReply { + success: boolean + match?: boolean + targetMd5?: string + targetEndian?: 'le' | 'be' + error?: string +} + class MainProcessBridge implements MainIpcModule { ipcMain mainWindow @@ -1682,22 +1695,27 @@ class MainProcessBridge implements MainIpcModule { * second client, and nothing to reconnect afterwards. Runtime v4 reads it over * its own WebSocket, which is a different protocol to a different target. */ - handleDebuggerVerifyMd5 = async ( - _event: IpcMainInvokeEvent, - expectedMd5: string, - ): Promise<{ - success: boolean - match?: boolean - targetMd5?: string - targetEndian?: 'le' | 'be' - error?: string - }> => { + handleDebuggerVerifyMd5 = async (_event: IpcMainInvokeEvent, expectedMd5: string): Promise => { try { - const channel = await this.requireDebug('verify md5') - if ('error' in channel) return { success: false, error: channel.error } - - const probe = await channel.client.getMd5Hash() - return { success: true, match: matchesMd5(probe.md5, expectedMd5), ...probe } + return await this.withDebugChannel( + 'verify md5', + async (client) => { + const probe = await client.getMd5Hash() + // `targetMd5` spelled out rather than spread: `Md5ProbeResult` names the + // hash `md5`, so `...probe` silently left the declared `targetMd5` + // undefined — and TypeScript does not apply excess-property checks to a + // spread, so nothing caught it. The mismatch report then read + // "MD5 mismatch. Target: undefined", losing the one value that tells the + // user which program is actually on the board. + return { + success: true, + match: matchesMd5(probe.md5, expectedMd5), + targetMd5: probe.md5, + targetEndian: probe.targetEndian, + } + }, + (reason) => ({ success: false, error: reason.error }), + ) } catch (error) { return { success: false, @@ -1806,19 +1824,22 @@ class MainProcessBridge implements MainIpcModule { // connection dropped, the manager is already reopening it (or has reported it // lost), and `needsReconnect` tells the renderer to stop the session rather // than race it for the medium. - const link = await this.requireDebug('read variables') - if ('error' in link) return { success: false, error: link.error, needsReconnect: true } - try { - const result = await link.client.getVariablesList(variableIndexes) - if (result.success && result.data) { - // The debug poll is the busiest thing on the link; telling the session - // about it is what stops the liveness read from queueing behind this - // traffic and timing out on a link that is plainly working. - this.deviceSession.noteTraffic() - return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } - } - return { success: false, error: result.error } + return await this.withDebugChannel( + 'read variables', + async (client) => { + const result = await client.getVariablesList(variableIndexes) + if (result.success && result.data) { + // The debug poll is the busiest thing on the link; telling the session + // about it is what stops the liveness read from queueing behind this + // traffic and timing out on a link that is plainly working. + this.deviceSession.noteTraffic() + return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } + } + return { success: false, error: result.error } + }, + (reason) => ({ success: false, error: reason.error, needsReconnect: true }), + ) } catch (error) { return { success: false, error: getErrorMessage(error), needsReconnect: true } } @@ -2227,6 +2248,11 @@ class MainProcessBridge implements MainIpcModule { * The DEBUG channel, opening it if this session's debug medium is one of its own. * Every debug caller passes a distinct `what`, which doubles as the holder name — * so two callers can hold it at once without either closing it on the other. + * + * A holder acquired here MUST be released, or the channel can never close. Only + * the debug session itself is a long-lived holder (acquired by `debugger:connect`, + * released by `debugger:disconnect`); every per-command caller goes through + * `withDebugChannel`, which releases in a `finally`. */ private async requireDebug(what: string): Promise<{ client: DeviceDebugChannel } | ChannelUnavailable> { const acquired = await this.deviceSession.acquireDebugChannel(what) @@ -2239,6 +2265,37 @@ class MainProcessBridge implements MainIpcModule { return acquired } + /** + * Run one command over the DEBUG channel, holding it only for the duration. + * + * The holder set is a reference count, and a per-command caller is not a holder + * of the channel's LIFETIME — it just needs the channel to exist while it runs. + * Registering those callers permanently is what kept a Runtime v3/v4 debug channel + * open after the debug session ended: `read variables` is acquired on every poll + * tick, so once one had run, `releaseDebugChannel('debug session')` always found + * the set non-empty and skipped the close. The user stopped debugging and the + * editor held an authenticated debug channel to their PLC until they logged out. + * + * Releasing here is safe for a BAREMETAL target, where control and debug are the + * same channel: `releaseDebugChannel` returns early on `debugCandidate === null` + * before touching any client, so it can never disconnect the device out from + * under run/stop or the status poll. Only a session whose debug medium is its + * own — v3's second Modbus TCP connection, v4's WebSocket — is ever closed. + */ + private async withDebugChannel( + what: string, + run: (client: DeviceDebugChannel) => Promise, + onUnavailable: (reason: ChannelUnavailable) => T, + ): Promise { + const acquired = await this.requireDebug(what) + if ('error' in acquired) return onUnavailable(acquired) + try { + return await run(acquired.client) + } finally { + this.deviceSession.releaseDebugChannel(what) + } + } + /** * Which channel served which command — logged ONCE per distinct combination. * @@ -2318,6 +2375,11 @@ class MainProcessBridge implements MainIpcModule { const linkCandidates = this.toDeviceLinkCandidates(candidates) if (linkCandidates.length === 0) { + // Publish a settled state before returning: this path never reaches + // `deviceSession.open()`, so nothing else will, and the renderer set + // 'connecting' the moment the user clicked. Left unsaid, its Connect button + // stays disabled for the rest of the project's life. + this.emitDeviceLinkStatus({ status: 'disconnected' }) return { status: 'error', error: 'No usable serial or Modbus TCP connection was configured for this device.' } } @@ -2351,19 +2413,20 @@ class MainProcessBridge implements MainIpcModule { ): Promise<{ success: boolean; error?: string }> => { const buffer = valueBuffer ? Buffer.from(valueBuffer) : undefined - const link = await this.requireDebug('write variable') - if ('error' in link) { - return { success: false, error: link.error } - } - try { - const result = await link.client.setVariable(variableIndex, force, buffer) - // Forcing values is device traffic too: it queues on the same link and - // proves the same thing a read does. Without this, holding a force while - // the poll is due lets the liveness read wait behind it and time out. - if (result.success) this.deviceSession.noteTraffic() - logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) - return result + return await this.withDebugChannel( + 'write variable', + async (client) => { + const result = await client.setVariable(variableIndex, force, buffer) + // Forcing values is device traffic too: it queues on the same link and + // proves the same thing a read does. Without this, holding a force while + // the poll is due lets the liveness read wait behind it and time out. + if (result.success) this.deviceSession.noteTraffic() + logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) + return result + }, + (reason) => ({ success: false, error: reason.error }), + ) } catch (error) { logger.error('[IPC Handler] Modbus setVariable error: ' + getErrorMessage(error)) return { success: false, error: getErrorMessage(error) } diff --git a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts index bbbece386..dca64d16d 100644 --- a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts @@ -226,6 +226,33 @@ describe('setPlcState', () => { void adapter.setPlcState?.('RUNNING') expect(window.bridge.debuggerPlcControl).toHaveBeenCalledWith('run') }) + + it('returns the acknowledgement the main process produced', async () => { + // `refusedBySwitch` / `unsupported` drive two distinct dialogs upstream, so the + // adapter must pass the whole shape through rather than reducing it to a boolean. + ;(window.bridge.debuggerPlcControl as jest.Mock).mockResolvedValue({ + success: false, + refusedBySwitch: true, + state: 0, + switchPosition: 0, + }) + + await expect(adapter.setPlcState?.('RUNNING')).resolves.toMatchObject({ + success: false, + refusedBySwitch: true, + }) + }) + + it('reports a rejected IPC call as a failed command instead of throwing', async () => { + // Run/stop is driven straight from a click handler. An escaping rejection would + // surface as an unhandled promise and the button would look like it did nothing. + ;(window.bridge.debuggerPlcControl as jest.Mock).mockRejectedValue(new Error('bridge is gone')) + + await expect(adapter.setPlcState?.('STOPPED')).resolves.toEqual({ + success: false, + error: 'bridge is gone', + }) + }) }) // --------------------------------------------------------------------------- diff --git a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts index 16bf956b4..def534176 100644 --- a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts @@ -29,6 +29,11 @@ beforeEach(() => { deviceConnect: jest.fn().mockResolvedValue({ status: 'connected-with-firmware' }), deviceDisconnect: jest.fn().mockResolvedValue({ success: true }), onDeviceConnectionStatus: jest.fn().mockReturnValue(() => undefined), + openRuntimeSession: jest.fn().mockResolvedValue({ success: true }), + closeRuntimeSession: jest.fn().mockResolvedValue({ success: true }), + deviceReleaseSerialPort: jest.fn().mockResolvedValue({ released: true }), + onDeviceLinkLog: jest.fn().mockReturnValue(() => undefined), + onDevicePlcState: jest.fn().mockReturnValue(() => undefined), } as unknown as typeof window.bridge }) @@ -98,4 +103,52 @@ describe('createEditorDeviceAdapter', () => { expect(window.bridge.onDeviceConnectionStatus).toHaveBeenCalledWith(cb) expect(typeof unsub).toBe('function') }) + + /** + * The session and handoff members. All pure IPC delegation, but each one is a + * name pairing between the port and the preload bridge — the kind of mismatch + * that type-checks on neither side once `window.bridge` is cast, and then fails + * only at runtime as "not a function" in the middle of a connect. + */ + it('delegates openRuntimeSession with the address and debug channel', async () => { + const params = { + address: '192.168.0.9', + debug: { connectionType: 'websocket' as const, connectionParams: { ipAddress: '192.168.0.9' } }, + } + const result = await adapter.openRuntimeSession?.(params) + expect(window.bridge.openRuntimeSession).toHaveBeenCalledWith(params) + expect(result).toMatchObject({ success: true }) + }) + + it('delegates closeRuntimeSession', async () => { + const result = await adapter.closeRuntimeSession?.() + expect(window.bridge.closeRuntimeSession).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ success: true }) + }) + + it('unwraps releaseSerialPort to the bare `released` flag', async () => { + // The caller decides whether to reconnect after an upload from this boolean, + // so the unwrapping is the part worth pinning — not the passthrough. + await expect(adapter.releaseSerialPort('COM5')).resolves.toBe(true) + expect(window.bridge.deviceReleaseSerialPort).toHaveBeenCalledWith('COM5') + }) + + it('reports releaseSerialPort false when nothing was held on that port', async () => { + ;(window.bridge.deviceReleaseSerialPort as jest.Mock).mockResolvedValue({ released: false }) + await expect(adapter.releaseSerialPort(null)).resolves.toBe(false) + }) + + it('delegates onLinkLog subscription and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onLinkLog?.(cb) + expect(window.bridge.onDeviceLinkLog).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) + + it('delegates onPlcState subscription and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onPlcState?.(cb) + expect(window.bridge.onDevicePlcState).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) }) diff --git a/src/middleware/shared/utils/target-capabilities/presets.ts b/src/middleware/shared/utils/target-capabilities/presets.ts index 80f1bd18e..9b97fb113 100644 --- a/src/middleware/shared/utils/target-capabilities/presets.ts +++ b/src/middleware/shared/utils/target-capabilities/presets.ts @@ -47,7 +47,12 @@ export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: false, - plcStateControl: false, + // v3 exposes the SAME run/stop REST API as v4 (`/api/start-plc`, + // `/api/stop-plc`, JWT-authenticated) — only the debug channel differs + // (v3: Modbus TCP, v4: WebSocket). The main process already routes the + // command over REST for both, so the only thing that ever stopped v3 + // was this flag. + plcStateControl: true, directUsbUpload: false, } diff --git a/src/middleware/shared/utils/target-capabilities/types.ts b/src/middleware/shared/utils/target-capabilities/types.ts index 1572c367f..4671405bd 100644 --- a/src/middleware/shared/utils/target-capabilities/types.ts +++ b/src/middleware/shared/utils/target-capabilities/types.ts @@ -94,10 +94,16 @@ export interface TargetCapabilities { isInProcessSimulator: boolean /** Target implements the runtime run/stop state machine, so the - * Start/Stop control is meaningful. Runtime v4 drives it over REST; - * arduino-cli targets drive it over the debugger transport (Modbus - * FC 0x49). Runtime v3 has its own web UI and is excluded; the - * Simulator keeps its dedicated start/stop path. */ + * Start/Stop control is meaningful. Runtime v3 AND v4 drive it over + * the same REST API (`/api/start-plc`, `/api/stop-plc`, both + * JWT-authenticated); arduino-cli targets drive it over the device + * connection (Modbus FC 0x4b). Only the Simulator is excluded, and + * only because it keeps its dedicated start/stop path. + * + * Runtime v3 having its own web UI is NOT a reason to exclude it: the + * editor's REST access is unaffected by that, the editor has shipped + * this button working against v3, and gating it off here made Start / + * Stop a silent no-op on a target where it had always worked. */ plcStateControl: boolean /** Upload happens over a local connection (USB / loopback) and From 1b9f1668f15d8df825027d50e343bf5c6ea512da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 17:59:16 -0300 Subject: [PATCH 35/79] fix(datatypes): wire undo/redo snapshots and dirty tracking for data type edits Datatype editors called the POU-keyed snapshot capture with a data type name, so every capture and undo/redo silently no-oped. Snapshot capture and snapshotActions.undo/redo now branch on data type names, restoring via the previously unwired projectActions.applyDatatypeSnapshot. Content edits (enum values, struct fields, array dimensions/base type/initial value) now mark the file and workspace unsaved via handleFileAndWorkspaceSavedState, matching the variables editor. The datatype header rename now goes through datatypeActions.rename, which validates the new name and rekeys the editor/tab/file entries instead of leaving the file slice orphaned. DOPE-534 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LCcPrjCcCF29NQbG8xbwA --- .../_features/[workspace]/data-type/index.tsx | 20 ++-- .../_molecules/data-types/array/index.tsx | 13 +- .../data-types/array/table/index.tsx | 2 + .../data-types/enumerated/index.tsx | 3 + .../data-types/enumerated/table/index.tsx | 2 + .../_molecules/data-types/structure/index.tsx | 3 + .../data-types/structure/table/index.tsx | 2 + src/frontend/hooks/use-pou-snapshot.ts | 9 +- .../store/__tests__/shared-slice.test.ts | 91 ++++++++++++++ src/frontend/store/slices/shared/slice.ts | 112 +++++++++++------- src/frontend/store/slices/shared/types.ts | 3 + 11 files changed, 199 insertions(+), 61 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index 42b1046a2..a177ff0e6 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -7,6 +7,7 @@ import { InputWithRef } from '../../../_atoms/input' import { ArrayDataType } from '../../../_molecules/data-types/array' import { EnumeratorDataType } from '../../../_molecules/data-types/enumerated' import { StructureDataType } from '../../../_molecules/data-types/structure' +import { toast } from '../../[app]/toast/use-toast' type DatatypeEditorProps = ComponentPropsWithoutRef<'div'> & { dataTypeName: string @@ -17,9 +18,7 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { project: { data: { dataTypes }, }, - tabsActions: { updateTabName }, - editorActions: { updateEditorModel }, - projectActions: { updateDatatype }, + datatypeActions: { rename }, searchQuery, } = useOpenPLCStore() const [editorContent, setEditorContent] = useState() @@ -52,14 +51,13 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { const handleBlur = (e: React.FocusEvent) => { const { value } = e.target if (dataTypeName !== value) { - // `updateDatatype` is a full replace. Spread the current - // entry so the rename only changes `name` — without this the - // entry would lose every other field (variable / values / - // dimensions / baseType / initialValue). - if (!editorContent) return - updateDatatype(dataTypeName, { ...editorContent, name: value }) - updateEditorModel(dataTypeName, value) - updateTabName(dataTypeName, value) + // `datatypeActions.rename` validates the new name and rekeys the + // editor model, tab, and file entry, then flags the file dirty. + const result = rename(dataTypeName, value) + if (!result.ok) { + setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) + toast({ title: 'Rename failed', description: result.message, variant: 'fail' }) + } setIsEditing(false) } } diff --git a/src/frontend/components/_molecules/data-types/array/index.tsx b/src/frontend/components/_molecules/data-types/array/index.tsx index a8ecec386..a09052535 100644 --- a/src/frontend/components/_molecules/data-types/array/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/index.tsx @@ -30,6 +30,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { data: { dataTypes }, }, libraries: sliceLibraries, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -92,17 +93,21 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const handleInitialValueChange = (e: ChangeEvent) => { setInitialValueData(e.target.value) + captureAndPush(editor.meta.name) const updatedData = { ...data } updatedData.initialValue = e.target.value updateDatatype(data.name, updatedData as PLCArrayDatatype) + handleFileAndWorkspaceSavedState(data.name) } const handleSelect = (definition: string, value: string) => { setBaseType(value) + captureAndPush(editor.meta.name) updateDatatype(data.name, { ...data, baseType: { value, definition }, } as PLCArrayDatatype) + handleFileAndWorkspaceSavedState(data.name) } // `updateDatatype` is a full replace — never pass a partial object, @@ -110,17 +115,15 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { // gets stripped and downstream selectors lose the entry. const writeDimensions = (newRows: PLCArrayDatatype['dimensions']) => { updateDatatype(data.name, { ...data, dimensions: newRows }) + handleFileAndWorkspaceSavedState(data.name) } const addNewRow = () => { + captureAndPush(editor.meta.name) + setTableData((prevRows) => { - const isFirst = prevRows.length === 0 const newRows = [...prevRows, { dimension: '' }] - if (isFirst) { - captureAndPush(editor.meta.name) - } - setArrayTable({ selectedRow: newRows.length - 1 }) writeDimensions(newRows) return newRows diff --git a/src/frontend/components/_molecules/data-types/array/table/index.tsx b/src/frontend/components/_molecules/data-types/array/table/index.tsx index ec1035076..8c4239190 100644 --- a/src/frontend/components/_molecules/data-types/array/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/table/index.tsx @@ -36,6 +36,7 @@ const DimensionsTable = ({ data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -48,6 +49,7 @@ const DimensionsTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'array') return updateDatatype(name, { ...current, dimensions: newDimensions }) + handleFileAndWorkspaceSavedState(name) } const columnHelper = createColumnHelper<{ dimension: string }>() diff --git a/src/frontend/components/_molecules/data-types/enumerated/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/index.tsx index 93a794df5..44a7f5a92 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/index.tsx @@ -19,6 +19,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { const { editor, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -46,6 +47,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { ...data, initialValue: value, }) + handleFileAndWorkspaceSavedState(data.name) } // `updateDatatype` is a full replace — spread `data` first so we @@ -53,6 +55,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { // downstream consumers. const writeValues = (newValues: PLCEnumeratedDatatype['values']) => { updateDatatype(data.name, { ...data, values: newValues }) + handleFileAndWorkspaceSavedState(data.name) } const addNewRow = () => { diff --git a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx index 4970dd488..0243d9f59 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx @@ -38,6 +38,7 @@ const EnumeratedTable = ({ data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -50,6 +51,7 @@ const EnumeratedTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'enumerated') return updateDatatype(name, { ...current, values: newValues }) + handleFileAndWorkspaceSavedState(name) } const columnHelper = createColumnHelper<{ description: string }>() diff --git a/src/frontend/components/_molecules/data-types/structure/index.tsx b/src/frontend/components/_molecules/data-types/structure/index.tsx index c5d934b20..fd20b81c0 100644 --- a/src/frontend/components/_molecules/data-types/structure/index.tsx +++ b/src/frontend/components/_molecules/data-types/structure/index.tsx @@ -21,6 +21,7 @@ const StructureDataType = () => { }, editorActions: { updateModelStructure }, projectActions: { updateDatatype, rearrangeStructureVariables }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -65,6 +66,7 @@ const StructureDataType = () => { const current = dataTypes.find((dt) => dt.name === editor.meta.name) if (!current || current.derivation !== 'structure') return updateDatatype(editor.meta.name, { ...current, variable: newVariables }) + handleFileAndWorkspaceSavedState(editor.meta.name) } const handleCreateStructureVariable = () => { @@ -178,6 +180,7 @@ const StructureDataType = () => { rowId: row ?? parseInt(editorStructure.selectedRow), newIndex: (row ?? parseInt(editorStructure.selectedRow)) + index, }) + handleFileAndWorkspaceSavedState(editor.meta.name) updateModelStructure({ selectedRow: parseInt(editorStructure.selectedRow) + index, }) diff --git a/src/frontend/components/_molecules/data-types/structure/table/index.tsx b/src/frontend/components/_molecules/data-types/structure/table/index.tsx index 2838a460d..390d8e365 100644 --- a/src/frontend/components/_molecules/data-types/structure/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/structure/table/index.tsx @@ -51,6 +51,7 @@ const StructureTable = ({ tableData, selectedRow, handleRowClick }: PLCStructure data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -85,6 +86,7 @@ const StructureTable = ({ tableData, selectedRow, handleRowClick }: PLCStructure return variable }), }) + handleFileAndWorkspaceSavedState(editor.meta.name) return { ok: true, message: 'Data updated successfully.' } } catch (error) { console.error('Failed to update data:', error) diff --git a/src/frontend/hooks/use-pou-snapshot.ts b/src/frontend/hooks/use-pou-snapshot.ts index e7f715e90..aeaa86cb5 100644 --- a/src/frontend/hooks/use-pou-snapshot.ts +++ b/src/frontend/hooks/use-pou-snapshot.ts @@ -7,6 +7,8 @@ import { flushFlowWriteBacks } from '../store/slices/shared/flow-writeback' * Convenience hook wrapping snapshotActions.pushToHistory(). * Captures the current POU state (variables, body, globalVariables, and * graphical flow state for LD/FBD) and pushes it to the undo history. + * Names that resolve to a data type instead of a POU capture the data + * type entry (`dataTypes`) so datatype editors share the same history. * * State is read via getState() at capture time (not subscribed): the hook * never re-renders its consumers and `captureAndPush` keeps a stable identity. @@ -29,7 +31,12 @@ export function usePouSnapshot() { if (flushFlowWriteBacks(useOpenPLCStore.getState, pouName).length > 0) return const { project, ladderFlows, fbdFlows } = useOpenPLCStore.getState() const pou = project.data.pous.find((p) => p.name === pouName) - if (!pou) return + if (!pou) { + const dataType = project.data.dataTypes.find((d) => d.name === pouName) + if (!dataType) return + pushToHistory(pouName, { variables: [], body: null, dataTypes: [dataType] }) + return + } pushToHistory(pouName, { variables: pou.interface?.variables ?? [], diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 87c7f22c0..4475d53a6 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1463,6 +1463,97 @@ describe('createSharedSlice', () => { expect(store.getState().project.data.pous.find((p) => p.name === 'Main')!.body.value).toBe('v2') }) }) + + // ----------------------------------------------------------------------- + // undo / redo for data types + // ----------------------------------------------------------------------- + describe('undo/redo for data types', () => { + const edited = { + name: 'Colors', + derivation: 'enumerated' as const, + values: [{ description: 'RED' }], + initialValue: '', + } + + beforeEach(() => { + store.getState().datatypeActions.create({ name: 'Colors', derivation: 'enumerated' }) + }) + + it('undo restores the snapshot data type and moves the current entry to future', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + + expect(store.getState().snapshotActions.undo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(initial) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(0) + expect(history.future).toHaveLength(1) + expect(history.future[0].dataTypes).toEqual([edited]) + }) + + it('redo reapplies the undone data type edit', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().snapshotActions.redo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(1) + expect(history.future).toHaveLength(0) + expect(history.past[0].dataTypes).toEqual([initial]) + }) + + it('undo marks the data type file saved when history returns to the saved depth', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.markSaved('Colors') + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().fileActions.updateFile({ name: 'Colors', saved: false }) + + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(true) + }) + + it('undo leaves the data type untouched when the snapshot has no dataTypes entry', () => { + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null }) + + expect(store.getState().snapshotActions.undo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(0) + expect(history.future).toHaveLength(1) + expect(history.future[0].dataTypes).toEqual([edited]) + }) + + it('redo leaves the data type untouched when the future snapshot has no dataTypes entry', () => { + store.getState().projectActions.updateDatatype('Colors', edited) + store.setState({ + undoRedo: { + Colors: { + past: [], + future: [{ variables: [], body: null }], + savedAtDepth: null, + }, + }, + }) + + expect(store.getState().snapshotActions.redo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(1) + expect(history.past[0].dataTypes).toEqual([edited]) + }) + }) }) // ========================================================================= diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index cc2b78116..00b78eb43 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1002,17 +1002,24 @@ const createSharedSlice: StateCreator = (s const snapshot = history.past[history.past.length - 1] const pou = state.project.data.pous.find((p) => p.name === pouName) - if (!pou) return true + const dataType = pou ? undefined : state.project.data.dataTypes.find((d) => d.name === pouName) // Save current state to future. Plain references — the store is // immer-managed (frozen, copy-on-write), so later edits can never // reach a captured snapshot. - const currentSnapshot: PouHistorySnapshot = { - variables: pou.interface?.variables ?? [], - body: pou.body.value, - ladderFlow: state.ladderFlows.find((f) => f.name === pouName), - fbdFlow: state.fbdFlows.find((f) => f.name === pouName), - globalVariables: state.project.data.configurations.resource.globalVariables, + let currentSnapshot: PouHistorySnapshot + if (pou) { + currentSnapshot = { + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: state.ladderFlows.find((f) => f.name === pouName), + fbdFlow: state.fbdFlows.find((f) => f.name === pouName), + globalVariables: state.project.data.configurations.resource.globalVariables, + } + } else if (dataType) { + currentSnapshot = { variables: [], body: null, dataTypes: [dataType] } + } else { + return true } setState( @@ -1025,22 +1032,27 @@ const createSharedSlice: StateCreator = (s }), ) - state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { - language: pou.body.language, - value: snapshot.body, - }) - if (snapshot.globalVariables) { - state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) - } - // Restore graphical flow state (nodes, edges, positions) - if (snapshot.ladderFlow) { - state.ladderFlowActions.applyLadderFlowSnapshot({ - editorName: pouName, - snapshot: snapshot.ladderFlow as LadderFlowType, + if (pou) { + state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { + language: pou.body.language, + value: snapshot.body, }) - } - if (snapshot.fbdFlow) { - state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + if (snapshot.globalVariables) { + state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) + } + // Restore graphical flow state (nodes, edges, positions) + if (snapshot.ladderFlow) { + state.ladderFlowActions.applyLadderFlowSnapshot({ + editorName: pouName, + snapshot: snapshot.ladderFlow as LadderFlowType, + }) + } + if (snapshot.fbdFlow) { + state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + } + } else { + const restoredDataType = snapshot.dataTypes?.[0] + if (restoredDataType) state.projectActions.applyDatatypeSnapshot(pouName, restoredDataType) } // Check if we've returned to the saved state @@ -1060,15 +1072,22 @@ const createSharedSlice: StateCreator = (s const snapshot = history.future[history.future.length - 1] const pou = state.project.data.pous.find((p) => p.name === pouName) - if (!pou) return true + const dataType = pou ? undefined : state.project.data.dataTypes.find((d) => d.name === pouName) // Save current state to past. Plain references — see undo. - const currentSnapshot: PouHistorySnapshot = { - variables: pou.interface?.variables ?? [], - body: pou.body.value, - ladderFlow: state.ladderFlows.find((f) => f.name === pouName), - fbdFlow: state.fbdFlows.find((f) => f.name === pouName), - globalVariables: state.project.data.configurations.resource.globalVariables, + let currentSnapshot: PouHistorySnapshot + if (pou) { + currentSnapshot = { + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: state.ladderFlows.find((f) => f.name === pouName), + fbdFlow: state.fbdFlows.find((f) => f.name === pouName), + globalVariables: state.project.data.configurations.resource.globalVariables, + } + } else if (dataType) { + currentSnapshot = { variables: [], body: null, dataTypes: [dataType] } + } else { + return true } setState( @@ -1081,22 +1100,27 @@ const createSharedSlice: StateCreator = (s }), ) - state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { - language: pou.body.language, - value: snapshot.body, - }) - if (snapshot.globalVariables) { - state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) - } - // Restore graphical flow state (nodes, edges, positions) - if (snapshot.ladderFlow) { - state.ladderFlowActions.applyLadderFlowSnapshot({ - editorName: pouName, - snapshot: snapshot.ladderFlow as LadderFlowType, + if (pou) { + state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { + language: pou.body.language, + value: snapshot.body, }) - } - if (snapshot.fbdFlow) { - state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + if (snapshot.globalVariables) { + state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) + } + // Restore graphical flow state (nodes, edges, positions) + if (snapshot.ladderFlow) { + state.ladderFlowActions.applyLadderFlowSnapshot({ + editorName: pouName, + snapshot: snapshot.ladderFlow as LadderFlowType, + }) + } + if (snapshot.fbdFlow) { + state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + } + } else { + const restoredDataType = snapshot.dataTypes?.[0] + if (restoredDataType) state.projectActions.applyDatatypeSnapshot(pouName, restoredDataType) } // Check if we've returned to the saved state diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 5025c4129..64ac74eed 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -1,6 +1,7 @@ import type { DeviceConfiguration, DevicePin, + PLCDataType, PLCProjectData, PLCVariable, ProjectMeta, @@ -62,6 +63,8 @@ export type PouHistorySnapshot = { globalVariables?: PLCVariable[] ladderFlow?: unknown fbdFlow?: unknown + /** Set when the history key is a data type instead of a POU. */ + dataTypes?: PLCDataType[] } export type PouHistory = { From 58bdcdaff91d50d70218b54dae87957fa849e42a Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 5 Aug 2026 17:42:45 -0400 Subject: [PATCH 36/79] refactor(debug): one published medium drives the debug poll on both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poller had three sources of truth for the same fact, and the browser's two ways of reaching a runtime had no name at all. What was wrong - `workspace.debugConnectionType` — a copy of the spec's channel kind, set at session start — drove batch size. - `session.debugTransport` ('http' | 'webrtc') in the WebRTC slice drove the cadence, behind a `!capabilities.isNativeApplication` platform check. - `backend/shared/debug/types.ts` declared a THIRD `DebugConnectionType` ('webrtc' | 'http' | 'simulator'), same name as the ports one but different members, stored by the debug bridge and read by nothing. - `deviceConnection.debugTransport` then added a fourth. They could and did disagree: a session whose medium was not yet known fell through to `'simulator'`, which is both the smallest batch (19 vs 500) and the fastest cadence (50ms) — and, because the RTU branch short-circuits first, it also defeated the deliberate 1000ms relay throttle. What replaces it `DebugMedium` names every medium a live session can ride, including the two the browser distinguishes and no spec does: `webrtc` (data channel to the orchestrator agent) and `http-relay` (browser -> Autonomy Edge -> agent websocket -> runtime). `DEBUG_MEDIUM_PROFILE` maps each to a batch size and a cadence, because those are two independent physical limits: batch is the frame budget at the far end (identical for websocket / webrtc / http-relay — all three terminate at the same debug socket on the runtime), cadence is link latency (200ms direct or peer-to-peer, 1000ms through two relays). The poller now reads one field, `deviceConnection.debugTransport`, published by the connection manager — the main process on the editor, the WebRTC lifecycle manager in the browser. It reads it LIVE, so a data channel that drops to the relay re-paces mid-session. The `isNativeApplication` branch is gone; nothing in the poller asks which platform it is on any more. Removed, not deprecated: `workspace.debugConnectionType` + its action, `session.debugTransport` (now `debugChannelOpen`, a boolean about the WebRTC channel rather than a second medium vocabulary), the vestigial `DebugConnectionType` in backend/shared/debug, and the debug bridge's unread transport label. `capabilities.debugHttpFallbackPollIntervalMs` is now `debugRelayPollIntervalMs`; its env key keeps the old spelling, being a deployment contract in other people's .env files. Co-Authored-By: Claude Opus 5 (1M context) --- .../editor/hardware/device-session-manager.ts | 9 +- src/backend/shared/debug/index.ts | 2 +- src/backend/shared/debug/types.ts | 10 +- .../workspace-activity-bar/default.tsx | 9 - .../__tests__/debug-medium-profile.test.ts | 77 ++++++++ src/frontend/hooks/useDebugPolling.ts | 167 ++++++++---------- src/frontend/hooks/useDebugSession.ts | 17 +- .../store/__tests__/webrtc-slice.test.ts | 26 +-- .../store/__tests__/workspace-slice.test.ts | 17 -- src/frontend/store/slices/device/types.ts | 17 +- src/frontend/store/slices/webrtc/index.ts | 9 +- src/frontend/store/slices/webrtc/slice.ts | 8 +- src/frontend/store/slices/webrtc/types.ts | 15 +- src/frontend/store/slices/workspace/slice.ts | 10 -- src/frontend/store/slices/workspace/types.ts | 6 - src/middleware/shared/ports/device-port.ts | 13 +- .../shared/ports/platform-capabilities.ts | 6 +- src/middleware/shared/ports/types.ts | 32 ++++ 18 files changed, 249 insertions(+), 201 deletions(-) create mode 100644 src/frontend/hooks/__tests__/debug-medium-profile.test.ts diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 6c27f658e..eb2662ad5 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -39,10 +39,13 @@ * main process implements as its existing classify + license recover; * - the counting for down / back / lost -> `DeviceLinkPolicy`. */ +import type { DebugMedium, DeviceLinkTransport } from '../../../middleware/shared/ports/types' import type { DeviceDebugChannel, DeviceModbusTransport } from '../../shared/debug/types' import { DeviceLinkPolicy } from './device-link-policy' -export type DeviceLinkTransport = 'rtu' | 'tcp' | 'simulator' +// Re-exported so callers in this layer keep one import site; the definition is +// shared with the renderer, which mirrors these media into the store. +export type { DebugMedium, DeviceLinkTransport } /** * How to open a DEBUG channel that is not the control channel. Simpler than a @@ -56,7 +59,7 @@ export interface DeviceDebugCandidate { * per round trip, Modbus TCP 60, RTU 19), and a poller that has to GUESS the * medium either wastes round trips or overruns a frame. */ - transport: DeviceLinkTransport | 'websocket' + transport: DebugMedium descriptor: string create: () => DeviceDebugChannel } @@ -111,7 +114,7 @@ export interface DeviceLinkStatus { * what "the connection dropped" means, the debug medium decides the poll's frame * budget. */ - debugTransport?: DeviceLinkTransport | 'websocket' + debugTransport?: DebugMedium descriptor?: string /** * Set only when a link that WAS up died and could not be recovered. The one diff --git a/src/backend/shared/debug/index.ts b/src/backend/shared/debug/index.ts index 8cc69ef57..3c4dfe443 100644 --- a/src/backend/shared/debug/index.ts +++ b/src/backend/shared/debug/index.ts @@ -1,2 +1,2 @@ export { ModbusRtuTransport } from './modbus-rtu-transport' -export type { DebugConnectionType, DebugSetResult, DebugTransport, DebugTransportResult } from './types' +export type { DebugSetResult, DebugTransport, DebugTransportResult } from './types' diff --git a/src/backend/shared/debug/types.ts b/src/backend/shared/debug/types.ts index 78e934069..1674ed6b9 100644 --- a/src/backend/shared/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -7,11 +7,15 @@ import type { PlcRuntimeState } from '../simulator/types' * Mirrors the implicit interface from openplc-editor where ModbusTcpClient, * ModbusRtuClient, and WebSocketDebugClient all implement the same methods. * - * openplc-web transports: ModbusRtuTransport (simulator), ModbusDataChannelTransport (WebRTC), HttpTransport. + * openplc-web transports: ModbusRtuTransport (simulator), ModbusDataChannelTransport + * (a WebRTC data channel, falling back to the Autonomy Edge relay per request). + * + * Which medium a session ends up on is NOT named here: the connection manager + * publishes it as a `DebugMedium` (middleware/shared/ports/types), which is the one + * vocabulary the debug poller reads. A second, near-identical union living here is + * how the poller came to have two disagreeing sources for the same fact. */ -export type DebugConnectionType = 'webrtc' | 'http' | 'simulator' - export interface DebugTransportResult { success: boolean tick?: number diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 63645144f..f50a79eac 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -708,15 +708,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (verifyResult.match) { consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'MD5 verified. Starting debugger...' }) - // The debug poll sizes its batches to the frame budget, so it needs the - // medium the DEBUG channel rides — published by the manager and mirrored in - // the store. Reading the control medium instead left a v4 session (control - // over REST, debug over a WebSocket) with no medium at all, and it silently - // polled with TCP-sized batches: 60 variables per round trip instead of 500. - const activeTransport = useOpenPLCStore.getState().deviceConnection.debugTransport - if (activeTransport) { - useOpenPLCStore.getState().workspaceActions.setDebugConnectionType(activeTransport) - } // Persist the target's byte order — detected from the MD5 // response trailer in the runtime — so the swap layer at the // read / write boundaries flips on BE targets. Default to diff --git a/src/frontend/hooks/__tests__/debug-medium-profile.test.ts b/src/frontend/hooks/__tests__/debug-medium-profile.test.ts new file mode 100644 index 000000000..cd3013ba3 --- /dev/null +++ b/src/frontend/hooks/__tests__/debug-medium-profile.test.ts @@ -0,0 +1,77 @@ +/** + * How the debug poll is paced and sized, per medium. + * + * This table is the whole reason the poller no longer asks which platform it is + * running on. It replaced two independent sources — a copy of the spec's channel + * kind (for batch size) and a WebRTC-slice flag behind an `isNativeApplication` + * check (for cadence) — which could and did disagree. + * + * The invariants below are the ones that were violated in practice, so they are + * asserted as properties rather than as a snapshot of the numbers. + */ +import type { DebugMedium } from '@root/middleware/shared/ports/types' + +import { DEBUG_MEDIUM_PROFILE, debugProfileFor } from '../useDebugPolling' + +/** Every medium the type admits. A new one must be added here deliberately. */ +const ALL_MEDIA: DebugMedium[] = ['rtu', 'simulator', 'tcp', 'websocket', 'webrtc', 'http-relay'] + +describe('DEBUG_MEDIUM_PROFILE', () => { + it('covers every medium, with no extras', () => { + // A medium with no row would fall through to `undefined` and crash the poller + // on `profile.batchSize`. + expect(Object.keys(DEBUG_MEDIUM_PROFILE).sort()).toEqual([...ALL_MEDIA].sort()) + }) + + it.each(ALL_MEDIA)('%s has a usable batch size and cadence', (medium) => { + const { batchSize, pollIntervalMs } = DEBUG_MEDIUM_PROFILE[medium] + expect(batchSize).toBeGreaterThan(1) + expect(pollIntervalMs).toBeGreaterThan(0) + }) + + it('sizes serial-framed media to one USB-CDC packet', () => { + // 6 + 3·19 = 63 ≤ 64. A 20th variable splits the request across two packets, + // which older serial framers drop. + for (const medium of ['rtu', 'simulator'] as const) { + expect(DEBUG_MEDIUM_PROFILE[medium].batchSize).toBe(19) + expect(6 + 3 * DEBUG_MEDIUM_PROFILE[medium].batchSize).toBeLessThanOrEqual(64) + } + }) + + it('gives every runtime medium the same batch size', () => { + // websocket / webrtc / http-relay all terminate at the SAME debug socket on the + // runtime, so they share its frame budget. Only the hops in front of it differ. + const runtimeMedia = ['websocket', 'webrtc', 'http-relay'] as const + const sizes = new Set(runtimeMedia.map((m) => DEBUG_MEDIUM_PROFILE[m].batchSize)) + expect(sizes.size).toBe(1) + expect(DEBUG_MEDIUM_PROFILE.websocket.batchSize).toBe(500) + }) + + it('backs the relay cadence off well below the direct media', () => { + // The regression this guards: a v4 web session fell through to the simulator + // row, polling the Edge relay every 50ms instead of every 1000ms. + expect(DEBUG_MEDIUM_PROFILE['http-relay'].pollIntervalMs).toBeGreaterThan( + DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs, + ) + expect(DEBUG_MEDIUM_PROFILE['http-relay'].pollIntervalMs).toBe(1000) + }) + + it('paces a peer-to-peer data channel like any other direct link', () => { + // WebRTC reaches the agent directly, so it is not the relay's problem. + expect(DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs).toBe(DEBUG_MEDIUM_PROFILE.websocket.pollIntervalMs) + expect(DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs).toBe(200) + }) +}) + +describe('debugProfileFor', () => { + it.each(ALL_MEDIA)('returns the %s row', (medium) => { + expect(debugProfileFor(medium)).toBe(DEBUG_MEDIUM_PROFILE[medium]) + }) + + it('falls back to tcp when the manager has published no medium yet', () => { + // Middle of the range, and what the poller defaulted to before the media were + // named. Deliberately NOT the simulator row, which is the fastest cadence and + // the smallest batch — the worst possible guess for an unknown remote link. + expect(debugProfileFor(null)).toBe(DEBUG_MEDIUM_PROFILE.tcp) + }) +}) diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index 7805f45cf..c5d699fd0 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -17,71 +17,69 @@ * - Diagram/source scan results are cached per {pouName, language, fbContext} * since the editor is read-only during debug * - * Polling intervals: - * - Modbus RTU / simulator: 50ms (no network; keep the UI snappy) - * - Web HTTP fallback: platform-capability-driven, 1000ms by default - * (WebRTC failed; each poll is a slow - * orchestrator round-trip, so back off) - * - Everything else: 200ms (general purpose — TCP / WebSocket / - * web WebRTC data channel) + * Batch size and poll interval both come from the session's medium — see + * `DEBUG_MEDIUM_PROFILE`. */ import { useCallback, useEffect, useRef } from 'react' -import type { DebugConnectionType, DebugTreeNode } from '../../middleware/shared/ports/types' +import type { DebugMedium, DebugTreeNode } from '../../middleware/shared/ports/types' import { useCapabilities, useDebugger } from '../../middleware/shared/providers' import { openPLCStoreBase, useOpenPLCStore } from '../store' import { buildActiveIndexSet } from '../utils/debug-polling-filter' import { applySwapToVariableBytes } from '../utils/endian' import { getTypeSizeByName, parseValueByTypeName } from '../utils/variable-sizes' -/** Polling interval for transports with serial framing (RTU / simulator). */ -const RTU_POLL_INTERVAL_MS = 50 -/** Polling interval for higher-bandwidth transports (TCP / WebSocket). */ -const DEFAULT_POLL_INTERVAL_MS = 200 - -// Batch size is transport-dependent. The wire request packs 3 bytes per -// variable (arr:u8 + elem:u16); the response packs raw type-sized values -// after a small header. The right ceiling is set by the transport's -// frame budget and the runtime's MAX_DEBUG_FRAME — never the target -// board, since the same board can run over RTU or TCP depending on the -// user's communication preferences. -// -// Modbus RTU : capped at 19 so the REQUEST stays ≤63 bytes and -// fits in a single 64-byte USB-CDC packet. A 20-var -// request is 6 + 3·20 = 66 bytes, which a USB-CDC -// target (e.g. SAMD21 / P1AM-100) receives split -// across two USB packets; older firmware whose serial -// framer can't reassemble a multi-packet request then -// drops it. Newer firmware (length-aware handle_serial) -// handles any size, but 19 keeps us compatible with -// field devices on both. 19·3 + 6 = 63 ≤ 64. -// Modbus TCP : Arduino sketch's MAX_MB_FRAME caps it; 60 is -// well within the headroom. -// WebSocket (Runtime v4) : Linux runtime's MAX_DEBUG_FRAME=4096; ~500 -// vars fits comfortably with room for value -// bytes. Anything bigger is unusual and the -// ERROR_OUT_OF_MEMORY fallback halves us back -// down to a safe size. -// Simulator : virtual serial port mirrors the RTU framing, -// so it shares the RTU ceiling. -const RTU_BATCH_SIZE = 19 -const TCP_BATCH_SIZE = 60 -const WEBSOCKET_BATCH_SIZE = 500 +/** + * How to pace and size the debug poll, per medium. + * + * These are two INDEPENDENT physical limits, which is why they live in one table + * rather than being derived from each other: + * + * `batchSize` — the frame budget at the far end. The request packs 3 bytes per + * variable (arr:u8 + elem:u16) and the response packs raw type-sized values after + * a small header. It is a property of the TARGET, never of the board the user + * picked, since the same board can be reached over RTU or TCP. + * rtu / simulator : 19, so the request stays ≤63 bytes and fits one 64-byte + * USB-CDC packet (6 + 3·19 = 63). A 20-variable request is 66 + * bytes, which a SAMD21 / P1AM-100 receives split across two + * packets — older firmware whose serial framer cannot + * reassemble then drops it. The simulator's virtual serial + * port mirrors the same framing. + * tcp : the Arduino sketch's MAX_MB_FRAME caps it; 60 has headroom. + * websocket / : the Linux runtime's MAX_DEBUG_FRAME=4096 — ~500 variables + * webrtc / with room for value bytes. All three reach the SAME debug + * http-relay socket on the runtime, so they share its budget; only the + * number of hops in front of it differs. + * + * `pollIntervalMs` — round-trip latency of the link. + * rtu / simulator : 50ms, no network in the way; keep the UI responsive. + * tcp / websocket : 200ms, one network hop. + * webrtc : 200ms, peer-to-peer to the agent — as direct as it gets. + * http-relay : 1000ms. Every poll is browser -> Edge -> agent websocket -> + * runtime and back. Polling this at the direct rate buries the + * relay in requests for data that cannot arrive any faster. + * Overridable per deployment via + * `capabilities.debugRelayPollIntervalMs`. + * + * A medium the caller has not published yet reads as `tcp` — the middle of the + * range, and what this defaulted to before the media were named. + */ +export const DEBUG_MEDIUM_PROFILE: Record = { + rtu: { batchSize: 19, pollIntervalMs: 50 }, + simulator: { batchSize: 19, pollIntervalMs: 50 }, + tcp: { batchSize: 60, pollIntervalMs: 200 }, + websocket: { batchSize: 500, pollIntervalMs: 200 }, + webrtc: { batchSize: 500, pollIntervalMs: 200 }, + 'http-relay': { batchSize: 500, pollIntervalMs: 1000 }, +} + +const DEFAULT_MEDIUM: DebugMedium = 'tcp' const MIN_BATCH_SIZE = 2 -function batchSizeForTransport(transport: DebugConnectionType | null): number { - switch (transport) { - case 'websocket': - return WEBSOCKET_BATCH_SIZE - case 'rtu': - case 'simulator': - return RTU_BATCH_SIZE - case 'tcp': - case null: - default: - return TCP_BATCH_SIZE - } +/** The profile for a medium, tolerating one not yet published. */ +export function debugProfileFor(medium: DebugMedium | null): { batchSize: number; pollIntervalMs: number } { + return DEBUG_MEDIUM_PROFILE[medium ?? DEFAULT_MEDIUM] } interface LeafMeta { @@ -136,13 +134,6 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const capabilities = useCapabilities() const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const { workspaceActions, consoleActions } = useOpenPLCStore() - // Web-only: which transport the debug session is actually running over. - // 'http' means WebRTC is unavailable and every poll is a slow - // orchestrator round-trip — so we back the cadence off (see below). - // On the desktop editor this is the unused 'http' default; the - // `!isNativeApplication` guard keeps the editor's real TCP/WebSocket - // transports on the standard 200ms regardless. - const sessionDebugTransport = useOpenPLCStore((state) => state.session.debugTransport) // Targeted selectors for active-index cache invalidation. // These only change on user interaction (not every poll cycle). @@ -159,10 +150,9 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const batchOffsetRef = useRef(0) const isPollingRef = useRef(false) - // Dynamic batch size — overwritten with the transport-specific - // ceiling on session start; halves on ERROR_OUT_OF_MEMORY and - // resets on the next session start. - const batchSizeRef = useRef(TCP_BATCH_SIZE) + // Dynamic batch size — overwritten with the medium's ceiling on session start; + // halves on ERROR_OUT_OF_MEMORY and resets on the next session start. + const batchSizeRef = useRef(DEBUG_MEDIUM_PROFILE[DEFAULT_MEDIUM].batchSize) // Full leaf index→metadata map — computed once when debugger starts. // One index → many leaves (a shared global appears under each POU's key). @@ -403,36 +393,29 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const pollRef = useRef(pollVariables) pollRef.current = pollVariables - // The transport in use for the current debug session. This drives both - // the batch size and the poll interval — neither should be conditioned - // on board target since the same board can speak RTU, TCP, etc. The - // workspace activity bar sets this when the debug session connects. - const debugConnectionType = useOpenPLCStore((state) => state.workspace.debugConnectionType) + // The medium this session is actually riding, published by the connection + // manager — the one place that knows. Read LIVE rather than latched at session + // start, because on web it can change mid-session: a WebRTC data channel that + // drops falls back to the Edge relay, and the cadence has to follow it down. + const debugMedium = useOpenPLCStore((state) => state.deviceConnection.debugTransport) // Set up polling interval when debugger becomes visible. useEffect(() => { if (isDebuggerVisible) { + const profile = debugProfileFor(debugMedium) + // Reset state on session start - batchSizeRef.current = batchSizeForTransport(debugConnectionType) + batchSizeRef.current = profile.batchSize batchOffsetRef.current = 0 lastResponseTimestampRef.current = 0 activeIndexesRef.current = null visibleVarsCacheRef.current = null - // RTU framing also covers the simulator's virtual serial port — - // both need the tighter cadence to keep up with toggling state. - const usesRtuFraming = debugConnectionType === 'rtu' || debugConnectionType === 'simulator' - // Web HTTP fallback: WebRTC unavailable, so reads go over the - // orchestrator proxy (high latency) — slow the cadence right down. - // Gated on `!isNativeApplication` so the desktop editor's real - // TCP/WebSocket transports never hit this branch (their - // `session.debugTransport` is an unused 'http' default). - const usesHttpFallback = !capabilities.isNativeApplication && sessionDebugTransport === 'http' - const pollIntervalMs = usesRtuFraming - ? RTU_POLL_INTERVAL_MS - : usesHttpFallback - ? capabilities.debugHttpFallbackPollIntervalMs - : DEFAULT_POLL_INTERVAL_MS + // One lookup, both axes. The medium already distinguishes a peer-to-peer + // data channel from the Edge relay, so nothing here needs to ask which + // platform it is running on. + const pollIntervalMs = + debugMedium === 'http-relay' ? capabilities.debugRelayPollIntervalMs : profile.pollIntervalMs // Fire first poll immediately, then schedule at fixed rate // Skip tick if previous poll is still in progress (isPolling guard) @@ -480,18 +463,10 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void visibleVarsCacheRef.current = null batchOffsetRef.current = 0 } - // `sessionDebugTransport` + `capabilities.isNativeApplication` are - // in the deps so the cadence re-evaluates if WebRTC drops to the HTTP - // fallback (or recovers) mid-session — the effect tears down the old - // interval and restarts at the new rate. - }, [ - isDebuggerVisible, - debugConnectionType, - sessionDebugTransport, - capabilities.isNativeApplication, - capabilities.debugHttpFallbackPollIntervalMs, - workspaceActions, - ]) + // `debugMedium` is in the deps so the cadence re-evaluates when a WebRTC data + // channel drops to the Edge relay (or recovers) mid-session — the effect tears + // down the old interval and restarts at the new rate. + }, [isDebuggerVisible, debugMedium, capabilities.debugRelayPollIntervalMs, workspaceActions]) // Clean up on unmount useEffect(() => { diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index 8581b85a0..aff9c47ea 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -176,18 +176,11 @@ export function useDebugSession(): UseDebugSessionReturn { const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) - // Record the active transport so useDebugPolling picks the right - // poll cadence + batch size. Set on EVERY start path (runtime - // targets also set it earlier in handleMd5Verification; this - // additionally covers the simulator path, which doesn't go - // through MD5 verification — without it the simulator stayed at - // the default 200ms instead of its intended 50ms). Must be set - // before `setDebuggerVisible(true)`, which is what triggers the - // polling effect. - // The medium is the manager's choice, mirrored in the store; read it rather - // than assuming one. `debugTransport` because this sizes the debug poll. - wsActions.setDebugConnectionType(useOpenPLCStore.getState().deviceConnection.debugTransport ?? 'simulator') - + // Nothing to record about the transport: `useDebugPolling` reads the medium + // the connection manager published (`deviceConnection.debugTransport`) and + // derives both its batch size and its cadence from it. Copying that into a + // second store field is what let the two disagree — and made a session whose + // medium was not yet known silently poll as if it were the simulator. wsActions.setDebuggerVisible(true) logActions.addLog({ id: crypto.randomUUID(), diff --git a/src/frontend/store/__tests__/webrtc-slice.test.ts b/src/frontend/store/__tests__/webrtc-slice.test.ts index 5dbfab104..1b0537cf9 100644 --- a/src/frontend/store/__tests__/webrtc-slice.test.ts +++ b/src/frontend/store/__tests__/webrtc-slice.test.ts @@ -28,7 +28,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) }) @@ -128,16 +128,16 @@ describe('createWebRTCSlice', () => { }) }) - describe('setDebugTransport', () => { + describe('setDebugChannelOpen', () => { it('switches transport to webrtc', () => { - store.getState().webrtcActions.setDebugTransport('webrtc') - expect(store.getState().session.debugTransport).toBe('webrtc') + store.getState().webrtcActions.setDebugChannelOpen(true) + expect(store.getState().session.debugChannelOpen).toBe(true) }) it('switches transport back to http', () => { - store.getState().webrtcActions.setDebugTransport('webrtc') - store.getState().webrtcActions.setDebugTransport('http') - expect(store.getState().session.debugTransport).toBe('http') + store.getState().webrtcActions.setDebugChannelOpen(true) + store.getState().webrtcActions.setDebugChannelOpen(false) + expect(store.getState().session.debugChannelOpen).toBe(false) }) }) @@ -167,7 +167,7 @@ describe('createWebRTCSlice', () => { it('does not modify sessionId, reconnectAttempt, or debugTransport', () => { store.getState().webrtcActions.setSessionId('existing-session') store.getState().webrtcActions.setReconnectAttempt(2) - store.getState().webrtcActions.setDebugTransport('webrtc') + store.getState().webrtcActions.setDebugChannelOpen(true) store.getState().webrtcActions.startSession({ deviceId: 'dev-1', @@ -178,7 +178,7 @@ describe('createWebRTCSlice', () => { const { session } = store.getState() expect(session.sessionId).toBe('existing-session') expect(session.reconnectAttempt).toBe(2) - expect(session.debugTransport).toBe('webrtc') + expect(session.debugChannelOpen).toBe(true) }) }) @@ -193,7 +193,7 @@ describe('createWebRTCSlice', () => { webrtcActions.setStatus('connected') webrtcActions.setError('some error') webrtcActions.setReconnectAttempt(3) - webrtcActions.setDebugTransport('webrtc') + webrtcActions.setDebugChannelOpen(true) webrtcActions.endSession() @@ -203,7 +203,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) it('preserves deviceId and deviceName after ending session', () => { @@ -230,7 +230,7 @@ describe('createWebRTCSlice', () => { webrtcActions.setStatus('connected') webrtcActions.setError('timeout') webrtcActions.setReconnectAttempt(5) - webrtcActions.setDebugTransport('webrtc') + webrtcActions.setDebugChannelOpen(true) webrtcActions.reset() @@ -242,7 +242,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) }) }) diff --git a/src/frontend/store/__tests__/workspace-slice.test.ts b/src/frontend/store/__tests__/workspace-slice.test.ts index d8fedc7f0..15e92a432 100644 --- a/src/frontend/store/__tests__/workspace-slice.test.ts +++ b/src/frontend/store/__tests__/workspace-slice.test.ts @@ -61,7 +61,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() }) // ------------------------------------------------------------------------- @@ -431,19 +430,6 @@ describe('createWorkspaceSlice', () => { expect(store.getState().workspace.debugMd5Mismatch).toBeNull() }) - it('setDebugConnectionType', () => { - expect(store.getState().workspace.debugConnectionType).toBeNull() - - store.getState().workspaceActions.setDebugConnectionType('websocket') - expect(store.getState().workspace.debugConnectionType).toBe('websocket') - - store.getState().workspaceActions.setDebugConnectionType('rtu') - expect(store.getState().workspace.debugConnectionType).toBe('rtu') - - store.getState().workspaceActions.setDebugConnectionType(null) - expect(store.getState().workspace.debugConnectionType).toBeNull() - }) - // ------------------------------------------------------------------------- // clearDebugState // ------------------------------------------------------------------------- @@ -482,7 +468,6 @@ describe('createWorkspaceSlice', () => { store.getState().workspaceActions.setDebugGraphList(['a']) store.getState().workspaceActions.setDebugDataStale(true) store.getState().workspaceActions.setDebugMd5Mismatch({ runtimeMd5: 'r', localMd5: 'l' }) - store.getState().workspaceActions.setDebugConnectionType('websocket') store.getState().workspaceActions.clearDebugState() @@ -503,7 +488,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() }) // ------------------------------------------------------------------------- @@ -616,7 +600,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() expect(workspace.isPlcLogsVisible).toBe(false) expect(workspace.plcLogs).toBe('') expect(workspace.plcLogsLastId).toBeNull() diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index daaf7251f..19f5ce22d 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -2,7 +2,9 @@ import type { EtherCATRuntimeStatusResponse } from '../../../../middleware/share import type { BoardInfo, CommunicationPort, + DebugMedium, DeviceConfiguration, + DeviceLinkTransport, DevicePin, PlcStatus, TimingStats, @@ -103,14 +105,17 @@ export type DeviceConnection = { * picks a transport. Null for a REST-controlled runtime session, which holds no * connection. */ - transport: 'rtu' | 'tcp' | 'simulator' | null + transport: DeviceLinkTransport | null /** - * Medium the DEBUG channel uses. The debug poll sizes its batches to this, because - * the frame budget differs enormously by wire (WebSocket 500 variables per round - * trip, Modbus TCP 60, RTU 19). Mirrored from the manager rather than inferred: a - * v4 session left this unset and silently polled with TCP-sized batches. + * Medium the DEBUG channel uses — the ONE fact the debug poller reads, for both + * its batch size and its cadence (see `DEBUG_MEDIUM_PROFILE`). Published by the + * connection manager, which is the only component that knows: the main process on + * the editor, the WebRTC lifecycle manager in the browser. + * + * Can change mid-session on web, when a WebRTC data channel drops to the Edge + * relay — the poller follows it, so this is read live rather than latched. */ - debugTransport: 'rtu' | 'tcp' | 'simulator' | 'websocket' | null + debugTransport: DebugMedium | null } // --------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/webrtc/index.ts b/src/frontend/store/slices/webrtc/index.ts index 219a7cd37..c17b16159 100644 --- a/src/frontend/store/slices/webrtc/index.ts +++ b/src/frontend/store/slices/webrtc/index.ts @@ -1,9 +1,2 @@ export { createWebRTCSlice } from './slice' -export type { - DebugTransport, - WebRTCActions, - WebRTCConnectionStatus, - WebRTCSession, - WebRTCSlice, - WebRTCState, -} from './types' +export type { WebRTCActions, WebRTCConnectionStatus, WebRTCSession, WebRTCSlice, WebRTCState } from './types' diff --git a/src/frontend/store/slices/webrtc/slice.ts b/src/frontend/store/slices/webrtc/slice.ts index 5878c8e9c..1e0aab35a 100644 --- a/src/frontend/store/slices/webrtc/slice.ts +++ b/src/frontend/store/slices/webrtc/slice.ts @@ -11,7 +11,7 @@ const initialSession: WebRTCSession = { status: 'disconnected', error: null, reconnectAttempt: 0, - debugTransport: 'http', + debugChannelOpen: false, } const createWebRTCSlice: StateCreator = (setState) => ({ @@ -67,10 +67,10 @@ const createWebRTCSlice: StateCreator = (setSt }), ) }, - setDebugTransport: (transport) => { + setDebugChannelOpen: (open) => { setState( produce(({ session }: WebRTCSlice) => { - session.debugTransport = transport + session.debugChannelOpen = open }), ) }, @@ -93,7 +93,7 @@ const createWebRTCSlice: StateCreator = (setSt session.status = 'disconnected' session.error = null session.reconnectAttempt = 0 - session.debugTransport = 'http' + session.debugChannelOpen = false }), ) }, diff --git a/src/frontend/store/slices/webrtc/types.ts b/src/frontend/store/slices/webrtc/types.ts index aab4d88ce..1c516b662 100644 --- a/src/frontend/store/slices/webrtc/types.ts +++ b/src/frontend/store/slices/webrtc/types.ts @@ -2,8 +2,6 @@ import type { WebRTCConnectionStatus } from '../../../../middleware/shared/ports export type { WebRTCConnectionStatus } -export type DebugTransport = 'http' | 'webrtc' - // --------------------------------------------------------------------------- // WebRTC session state // --------------------------------------------------------------------------- @@ -16,7 +14,16 @@ export type WebRTCSession = { status: WebRTCConnectionStatus error: string | null reconnectAttempt: number - debugTransport: DebugTransport + /** + * Is the WebRTC DEBUG data channel open? + * + * A fact about this WebRTC session, deliberately not a medium name: which + * medium the debug poller then rides is derived from this once, by the web + * connection manager, and published as `deviceConnection.debugTransport`. + * Naming a medium here as well gave two fields the same vocabulary and let + * them disagree. + */ + debugChannelOpen: boolean } export type WebRTCState = { @@ -35,7 +42,7 @@ export type WebRTCActions = { setStatus: (status: WebRTCConnectionStatus) => void setError: (error: string | null) => void setReconnectAttempt: (attempt: number) => void - setDebugTransport: (transport: DebugTransport) => void + setDebugChannelOpen: (open: boolean) => void startSession: (params: { deviceId: string; deviceName: string; agentId: string }) => void endSession: () => void reset: () => void diff --git a/src/frontend/store/slices/workspace/slice.ts b/src/frontend/store/slices/workspace/slice.ts index 86f132c1a..d461bc0af 100644 --- a/src/frontend/store/slices/workspace/slice.ts +++ b/src/frontend/store/slices/workspace/slice.ts @@ -53,7 +53,6 @@ const createWorkspaceSlice: StateCreator debugGraphList: [], debugDataStale: false, debugMd5Mismatch: null, - debugConnectionType: null, debugTargetEndian: 'le', // Project loading state isProjectLoading: false, @@ -173,7 +172,6 @@ const createWorkspaceSlice: StateCreator workspace.debugGraphList = [] workspace.debugDataStale = false workspace.debugMd5Mismatch = null - workspace.debugConnectionType = null workspace.debugTargetEndian = 'le' workspace.isPlcLogsVisible = false workspace.plcLogs = '' @@ -373,13 +371,6 @@ const createWorkspaceSlice: StateCreator }), ) }, - setDebugConnectionType: (connectionType) => { - setState( - produce(({ workspace }: WorkspaceSlice) => { - workspace.debugConnectionType = connectionType - }), - ) - }, setDebugTargetEndian: (endian) => { setState( produce(({ workspace }: WorkspaceSlice) => { @@ -406,7 +397,6 @@ const createWorkspaceSlice: StateCreator workspace.debugGraphList = [] workspace.debugDataStale = false workspace.debugMd5Mismatch = null - workspace.debugConnectionType = null workspace.debugTargetEndian = 'le' }), ) diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index 17b0f4c64..09cd89876 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -1,6 +1,5 @@ import type { Architecture, - DebugConnectionType, DebugTreeNode, FbInstanceInfo, Platform, @@ -97,10 +96,6 @@ export type WorkspaceState = { debugGraphList: string[] debugDataStale: boolean debugMd5Mismatch: { runtimeMd5: string; localMd5: string } | null - /** Active transport for the running debug session — drives the - * per-poll batch size and any other transport-specific behaviour. - * Null when no session is active. */ - debugConnectionType: DebugConnectionType | null /** Target's native byte order for multi-byte variable values on * the wire. Detected from the 0xDEAD sentinel in the MD5 * response: LE target writes the trailer as `[0xAD, 0xDE]`, BE @@ -185,7 +180,6 @@ export type WorkspaceActions = { setDebugGraphList: (list: string[]) => void setDebugDataStale: (stale: boolean) => void setDebugMd5Mismatch: (mismatch: { runtimeMd5: string; localMd5: string } | null) => void - setDebugConnectionType: (connectionType: DebugConnectionType | null) => void setDebugTargetEndian: (endian: 'le' | 'be') => void clearDebugState: () => void clearFbDebugContext: () => void diff --git a/src/middleware/shared/ports/device-port.ts b/src/middleware/shared/ports/device-port.ts index 8539f78d5..fad89c06d 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -21,7 +21,7 @@ * - getDeviceStatus() */ -import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from './types' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig, DebugMedium, DeviceLinkTransport } from './types' // --------------------------------------------------------------------------- // Connect-time classification (D72) — platform contract shared by the port and @@ -55,14 +55,15 @@ export interface DeviceConnectionStatusPayload { * Medium the CONTROL channel uses (or was using, when it dropped). Absent for a * REST-controlled runtime session: REST holds no connection. */ - transport?: 'rtu' | 'tcp' | 'simulator' + transport?: DeviceLinkTransport /** * Medium the DEBUG channel uses — the same as `transport` when one channel serves - * both roles, else `websocket` (v4) or `tcp` (v3). Consumers that must size work - * to the wire (the debug poll's frame budget) read THIS, rather than inferring a - * medium they have no business choosing. + * both roles, else `websocket` (editor / v4), `tcp` (v3), or `webrtc` / + * `http-relay` in the browser. Consumers that must pace or size work to the wire + * (the debug poll) read THIS, rather than inferring a medium they have no + * business choosing. */ - debugTransport?: 'rtu' | 'tcp' | 'simulator' | 'websocket' + debugTransport?: DebugMedium /** * The endpoint, as the user would name it: a serial path ("/dev/ttyACM0", * "COM5") or an IP address. Not called `port`, because for a Modbus TCP link it diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index 235ddc340..c943abc95 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -113,7 +113,7 @@ export interface PlatformCapabilities { * autonomy-node runs no WebRTC signaling relay and wants this to match * its general-purpose poll rate. */ - debugHttpFallbackPollIntervalMs: number + debugRelayPollIntervalMs: number // --- Environment --- @@ -150,7 +150,7 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasDirectProgramUpload: false, hasPackageManager: true, hasEthercat: true, - debugHttpFallbackPollIntervalMs: 1000, + debugRelayPollIntervalMs: 1000, isDevMode: false, } @@ -194,6 +194,6 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasDirectProgramUpload: true, hasPackageManager: false, hasEthercat: false, - debugHttpFallbackPollIntervalMs: 1000, + debugRelayPollIntervalMs: 1000, isDevMode: false, } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 8715b9010..eb644ceb4 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -1053,8 +1053,40 @@ export interface RuntimeLogEntry { // Debugger // --------------------------------------------------------------------------- +/** + * A channel kind a board's `debug` spec can declare. This is the SPEC's + * vocabulary — what a package author writes — not necessarily what a live + * session ends up riding. See `DebugMedium` for that. + */ export type DebugConnectionType = 'tcp' | 'rtu' | 'websocket' | 'simulator' +/** + * What a live debug session actually rides — the one fact the connection manager + * publishes and the debug poller consumes. + * + * Wider than `DebugConnectionType` because the browser reaches a runtime two ways + * that no spec distinguishes, and they behave differently enough that the poller + * must tell them apart: + * + * `webrtc` a data channel straight to the orchestrator agent, which relays + * to the runtime's debug socket. + * `http-relay` the same request, hop by hop: browser -> Autonomy Edge -> + * agent (over its always-on websocket) -> runtime. The fallback + * when a data channel cannot be opened. + * + * Both terminate at the SAME endpoint on the device, so they carry the same frame + * budget and differ only in latency — which is exactly the split + * `DEBUG_MEDIUM_PROFILE` encodes. + */ +export type DebugMedium = DebugConnectionType | 'webrtc' | 'http-relay' + +/** + * Media that can carry a CONTROL channel — one the connection manager physically + * holds open and polls. Narrower than `DebugMedium` on purpose: a REST-controlled + * runtime holds nothing, and the browser's media are debug-only. + */ +export type DeviceLinkTransport = 'rtu' | 'tcp' | 'simulator' + export interface DebugConnectionConfig { connectionType: DebugConnectionType connectionParams: { From 4de3a21d62363fd3f86f82dbb888c79ec9d02652 Mon Sep 17 00:00:00 2001 From: JulioSergioFS Date: Wed, 5 Aug 2026 20:05:21 -0300 Subject: [PATCH 37/79] fix(graphical-editor): type new variables from the block's generic pins (#479) --- .../fbd/autocomplete/index.tsx | 121 +++++++++++- .../ladder/autocomplete/index.tsx | 101 +++++++++- .../create-graphical-variable-modal.test.tsx | 135 +++++++++++++ .../create-graphical-variable-modal.tsx | 179 ++++++++++++++++++ .../components/_templates/app-layout.tsx | 13 ++ src/frontend/services/graphical-scope.ts | 33 ++-- src/frontend/store/slices/modal/slice.ts | 1 + src/frontend/store/slices/modal/types.ts | 37 ++++ .../__tests__/validate-variable-type.test.ts | 111 ++++++++++- .../utils/PLC/validate-variable-type.ts | 77 ++++++++ 10 files changed, 786 insertions(+), 22 deletions(-) create mode 100644 src/frontend/components/_organisms/modals/__tests__/create-graphical-variable-modal.test.tsx create mode 100644 src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx index 92d4dc968..d0f3ebe61 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx @@ -9,8 +9,11 @@ import { scopeCompletionToVariable, } from '../../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../../store' +import type { CreateGraphicalVariableModalData } from '../../../../../store/slices/modal/types' import { cn } from '../../../../../utils/cn' import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords' +import type { BoundBlockPin } from '../../../../../utils/PLC/validate-variable-type' +import { isGenericTypeName } from '../../../../../utils/PLC/validate-variable-type' import { toast } from '../../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context' import { buildGenericNode } from '../../../../_molecules/graphical-editor/fbd/fbd-utils/nodes' @@ -20,6 +23,53 @@ import { CustomFbdNodeTypes } from '..' import { BasicNodeData } from '../utils' import { getFBDPouVariablesRungNodeAndEdges } from '../utils/utils' +/** Minimal shape of an FBD rung this module needs — nodes plus their wiring. */ +type FBDRungGraph = { + nodes: Node[] + edges: { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null }[] +} + +const VARIABLE_BOX_TYPES = ['input-variable', 'output-variable', 'inout-variable'] + +/** + * The pins of the same block instance that already carry a variable. A generic + * pin (`ANY`, `ANY_NUM`, …) has no type of its own, so a variable created on it + * takes the concrete type the block already settled on elsewhere (#479). + * + * Walks the graph the same way `expectedType` does — from this box to the block + * it is wired to — and then across that block's remaining edges. + */ +const boundPinsOfConnectedBlock = (rung: FBDRungGraph, boxId: string, isInputBox: boolean): BoundBlockPin[] => { + const linkToBlock = rung.edges.find((edge) => (isInputBox ? edge.source === boxId : edge.target === boxId)) + const blockId = isInputBox ? linkToBlock?.target : linkToBlock?.source + if (!blockId) return [] + + const pinDefinitions = (rung.nodes.find((node) => node.id === blockId)?.data.variant as BlockVariant | undefined) + ?.variables + if (!pinDefinitions) return [] + + const pins: BoundBlockPin[] = [] + for (const edge of rung.edges) { + const intoBlock = edge.target === blockId + if (!intoBlock && edge.source !== blockId) continue + + const otherId = intoBlock ? edge.source : edge.target + if (otherId === boxId) continue + + // Only variable boxes carry a concrete type; a block on the other end + // exposes its own pins, not a variable's type. + const otherNode = rung.nodes.find((node) => node.id === otherId) + if (!otherNode || !VARIABLE_BOX_TYPES.includes(otherNode.type ?? '')) continue + + const pinType = pinDefinitions.find((pin) => pin.name === (intoBlock ? edge.targetHandle : edge.sourceHandle))?.type + .value + const variable = (otherNode.data as BasicNodeData | undefined)?.variable + const variableType = variable && 'type' in variable ? variable.type.value : undefined + if (pinType && variableType) pins.push({ pinType, variableType }) + } + return pins +} + type FBDBlockAutoCompleteProps = ComponentPropsWithRef<'div'> & { block: unknown isOpen?: boolean @@ -35,6 +85,7 @@ const FBDBlockAutoComplete = forwardRef state.projectActions.createVariable) const fbdFlows = useOpenPLCStore((state) => state.fbdFlows) const { updateNode, addNode } = useOpenPLCStore((state) => state.fbdFlowActions) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) const block = unknownBlock as Node & { positionAbsoluteX?: number; positionAbsoluteY?: number } const { edges, rung } = useMemo(() => { @@ -145,17 +196,77 @@ const FBDBlockAutoComplete = forwardRef createAndBindVariable(choice), + onCancel: clearBoundVariable, + } satisfies CreateGraphicalVariableModalData) + return + } + + createAndBindVariable({ name: variableName, class: 'local', type: variableType }) + } + + /** + * Empty this box. Opening the type dialog blurs the box, which binds the + * typed text as a raw reference — abandoning the dialog must not leave that + * dangling name behind. + */ + const clearBoundVariable = () => { + const { project, fbdFlows: freshFlows } = useOpenPLCStore.getState() + const { node } = getFBDPouVariablesRungNodeAndEdges(pouName, project.data.pous, freshFlows, { + nodeId: block.id, + }) + if (!node) return + + updateNode({ + editorName: pouName, + nodeId: node.id, + node: { ...node, data: { ...node.data, variable: { id: '', name: '' } } }, + }) + } + + /** + * Create the local variable and bind it to this box. Reads the rung/node + * fresh from the store because the type dialog may have resolved long after + * the dropdown that started this closed. + */ + const createAndBindVariable = ({ + name, + class: variableClass, + type, + }: { + name: string + class: PLCVariable['class'] + type: { definition: PLCVariable['type']['definition']; value: string } + }) => { + const { project, fbdFlows: freshFlows } = useOpenPLCStore.getState() + const { node } = getFBDPouVariablesRungNodeAndEdges(pouName, project.data.pous, freshFlows, { + nodeId: block.id, + }) + if (!node) return const res = createVariable({ data: { id: crypto.randomUUID(), - name: variableName, + name, type: { - definition: variableType.definition, - value: variableType.value, + definition: type.definition, + value: type.value, }, - class: 'local', + class: variableClass, location: '', documentation: '', debug: false, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx index 4a87655e0..7655b851e 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -10,8 +10,11 @@ import { scopeCompletionToVariable, } from '../../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../../store' +import type { CreateGraphicalVariableModalData } from '../../../../../store/slices/modal/types' import { cn } from '../../../../../utils/cn' import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords' +import type { BoundBlockPin } from '../../../../../utils/PLC/validate-variable-type' +import { isGenericTypeName } from '../../../../../utils/PLC/validate-variable-type' import { toast } from '../../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context' import { GraphicalEditorAutocomplete } from '../../autocomplete' @@ -48,6 +51,32 @@ const expectedTypeForBlock = ( } } +/** + * The pins of the same block instance that already carry a variable. A generic + * pin (`ANY`, `ANY_NUM`, …) has no type of its own, so a variable created on it + * takes the concrete type the block already settled on elsewhere (#479). + * + * Read from the rung's own variable nodes: each one knows both its pin's + * declared type and the variable bound to it. + */ +const boundPinsOfSameBlock = (nodes: Node[], variableNode: VariableNode): BoundBlockPin[] => { + const blockId = variableNode.data.block?.id + if (!blockId) return [] + + const pins: BoundBlockPin[] = [] + for (const node of nodes) { + if (node.id === variableNode.id) continue + const data = node.data as Partial + if (data.block?.id !== blockId) continue + + const pinType = data.block.variableType?.type?.value + const variable = data.variable + const variableType = variable && 'type' in variable ? variable.type.value : undefined + if (pinType && variableType) pins.push({ pinType, variableType }) + } + return pins +} + const VariablesBlockAutoComplete = forwardRef( ( { block, blockType = 'other', isOpen, setIsOpen, keyPressed, valueToSearch }: VariablesBlockAutoCompleteProps, @@ -57,6 +86,7 @@ const VariablesBlockAutoComplete = forwardRef state.project.data.pous) const createVariable = useOpenPLCStore((state) => state.projectActions.createVariable) const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) const expectedType = expectedTypeForBlock(block, blockType) @@ -202,17 +232,78 @@ const VariablesBlockAutoComplete = forwardRef createAndBindVariable(choice), + onCancel: clearBoundVariable, + } satisfies CreateGraphicalVariableModalData) + return + } + + createAndBindVariable({ name: variableName, class: 'local', type: variableType }) + } + + /** + * Empty this box. Opening the type dialog blurs the box, which binds the + * typed text as a raw reference — abandoning the dialog must not leave that + * dangling name behind. + */ + const clearBoundVariable = () => { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: (block as Node).id, + }) + if (!rung || !node) return + + updateNode({ + editorName: pouName, + rungId: rung.id, + nodeId: node.id, + node: { ...node, data: { ...node.data, variable: { id: '', name: '' } } }, + }) + } + + /** + * Create the local variable and bind it to this box. Reads the rung/node + * fresh from the store because the type dialog may have resolved long after + * the dropdown that started this closed. + */ + const createAndBindVariable = ({ + name, + class: variableClass, + type, + }: { + name: string + class: PLCVariable['class'] + type: { definition: PLCVariable['type']['definition']; value: string } + }) => { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: (block as Node).id, + }) + if (!rung || !node) return const res = createVariable({ data: { id: uuidv4(), - name: variableName, + name, type: { - definition: variableType.definition, - value: variableType.value, + definition: type.definition, + value: type.value, }, - class: 'local', + class: variableClass, location: '', documentation: '', debug: false, diff --git a/src/frontend/components/_organisms/modals/__tests__/create-graphical-variable-modal.test.tsx b/src/frontend/components/_organisms/modals/__tests__/create-graphical-variable-modal.test.tsx new file mode 100644 index 000000000..96a24660c --- /dev/null +++ b/src/frontend/components/_organisms/modals/__tests__/create-graphical-variable-modal.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +import type { CreateGraphicalVariableModalData } from '../../../../store/slices/modal/types' +import { CreateGraphicalVariableModal } from '../create-graphical-variable-modal' + +// Plain closures instead of vi.fn/jest.fn so the same file runs under the +// editor's jest and the web's vitest. +let confirmed: Parameters[0][] +let closes: number + +const makeData = (overrides: Partial = {}): CreateGraphicalVariableModalData => ({ + pinType: 'ANY_NUM', + name: 'dst', + suggestedType: { definition: 'base-type', value: 'INT' }, + onConfirm: (choice) => confirmed.push(choice), + ...overrides, +}) + +const renderModal = (data = makeData(), dataTypeNames: string[] = ['MyStruct']) => + render( + undefined} + onClose={() => { + closes += 1 + }} + />, + ) + +const typeOptionValues = () => + Array.from(screen.getByLabelText('Type').querySelectorAll('option')).map((option) => option.getAttribute('value')) + +describe('CreateGraphicalVariableModal', () => { + beforeEach(() => { + confirmed = [] + closes = 0 + }) + + it('pre-fills the name and the type the editor inferred', () => { + renderModal() + + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('dst') + expect((screen.getByLabelText('Type') as HTMLSelectElement).value).toBe('INT') + expect((screen.getByLabelText('Class') as HTMLSelectElement).value).toBe('local') + }) + + it('offers only the types a restricted generic accepts', () => { + renderModal(makeData({ pinType: 'ANY_REAL' })) + + expect(typeOptionValues()).toEqual(['REAL', 'LREAL']) + }) + + it('offers base types plus the project data types on a bare ANY pin', () => { + renderModal(makeData({ pinType: 'ANY', suggestedType: { definition: 'base-type', value: 'DINT' } })) + + const options = typeOptionValues() + expect(options).toContain('BOOL') + expect(options).toContain('DINT') + expect(options).toContain('MyStruct') + }) + + it('hands the edited name, class and type back to the caller and closes', () => { + renderModal() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'sink' } }) + fireEvent.change(screen.getByLabelText('Class'), { target: { value: 'output' } }) + fireEvent.change(screen.getByLabelText('Type'), { target: { value: 'LREAL' } }) + fireEvent.click(screen.getByText('Create')) + + expect(confirmed).toEqual([{ name: 'sink', class: 'output', type: { definition: 'base-type', value: 'LREAL' } }]) + expect(closes).toBe(1) + }) + + it('marks a user data type chosen on an ANY pin as user-data-type', () => { + renderModal(makeData({ pinType: 'ANY', suggestedType: { definition: 'base-type', value: 'DINT' } })) + + fireEvent.change(screen.getByLabelText('Type'), { target: { value: 'MyStruct' } }) + fireEvent.click(screen.getByText('Create')) + + expect(confirmed[0].type).toEqual({ definition: 'user-data-type', value: 'MyStruct' }) + }) + + it('trims the name and confirms on Enter in the name field', () => { + renderModal() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' spaced ' } }) + fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Enter' }) + + expect(confirmed).toHaveLength(1) + expect(confirmed[0].name).toBe('spaced') + }) + + it('creates nothing on cancel and lets the caller undo the box state', () => { + let cancels = 0 + renderModal(makeData({ onCancel: () => (cancels += 1) })) + + fireEvent.click(screen.getByText('Cancel')) + + expect(confirmed).toEqual([]) + expect(cancels).toBe(1) + expect(closes).toBe(1) + }) + + it('does not run the caller undo when the variable is created', () => { + let cancels = 0 + renderModal(makeData({ onCancel: () => (cancels += 1) })) + + fireEvent.click(screen.getByText('Create')) + + expect(confirmed).toHaveLength(1) + expect(cancels).toBe(0) + }) + + it('tolerates a payload without onCancel', () => { + renderModal() + + fireEvent.click(screen.getByText('Cancel')) + + expect(confirmed).toEqual([]) + expect(closes).toBe(1) + }) + + it('blocks confirmation while the name is blank', () => { + renderModal() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } }) + + expect((screen.getByText('Create') as HTMLButtonElement).disabled).toBe(true) + fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Enter' }) + expect(confirmed).toEqual([]) + expect(closes).toBe(0) + }) +}) diff --git a/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx b/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx new file mode 100644 index 000000000..3b74ebf1f --- /dev/null +++ b/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from 'react' + +import { baseTypeEnum } from '../../../../middleware/shared/ports/plc-schemas' +import type { VariableClass } from '../../../../middleware/shared/ports/types' +import type { CreateGraphicalVariableModalData } from '../../../store/slices/modal/types' +import { getVariableRestrictionType } from '../../../utils/PLC/validate-variable-type' +import { Label } from '../../_atoms/label' +import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' + +/** Classes worth offering for a variable created from a graphical box. */ +const VARIABLE_CLASSES: VariableClass[] = ['local', 'input', 'output', 'inOut', 'temp'] + +const inputClass = + 'w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-850 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + +/** + * Type dialog for "Add variable" on a generic block pin (issue #479). + * + * Deliberately store-free: the state it needs (project data types) and the + * closing behaviour arrive as props, so the same file is exercisable under the + * editor's jest and the web's vitest without mocking the store. + */ +type CreateGraphicalVariableModalProps = { + isOpen: boolean + data: CreateGraphicalVariableModalData + /** Project data-type names, offered when the pin is a bare `ANY`. */ + dataTypeNames: string[] + onOpenChange: (open: boolean) => void + onClose: () => void +} + +const CreateGraphicalVariableModal = ({ + isOpen, + data, + dataTypeNames, + onOpenChange, + onClose, +}: CreateGraphicalVariableModalProps) => { + const [name, setName] = useState(data.name) + const [variableClass, setVariableClass] = useState('local') + const [typeValue, setTypeValue] = useState(data.suggestedType.value) + + // A reused instance must never carry the previous pin's answers over. + useEffect(() => { + if (!isOpen) return + setName(data.name) + setVariableClass('local') + setTypeValue(data.suggestedType.value) + }, [isOpen, data.name, data.suggestedType.value]) + + /** + * Types the pin accepts. A restricted generic (`ANY_NUM`, `ANY_BIT`, …) + * flattens to its base-type set; a bare `ANY` accepts anything, so it gets + * every base type plus the project's own data types. + */ + const typeOptions = useMemo(() => { + const restriction = getVariableRestrictionType(data.pinType) + if (Array.isArray(restriction.values)) { + return restriction.values.map((value) => ({ value, definition: 'base-type' as const })) + } + return [ + ...baseTypeEnum.options.map((value) => ({ value, definition: 'base-type' as const })), + ...dataTypeNames + .filter((dataTypeName) => dataTypeName.length > 0) + .map((dataTypeName) => ({ value: dataTypeName, definition: 'user-data-type' as const })), + ] + }, [data.pinType, dataTypeNames]) + + const handleCancel = () => { + data.onCancel?.() + onClose() + } + + const handleConfirm = () => { + const trimmedName = name.trim() + if (!trimmedName) return + const selected = typeOptions.find((option) => option.value === typeValue) + data.onConfirm({ + name: trimmedName, + class: variableClass, + // An option the list doesn't know can only come from the suggestion, so + // keep the definition the editor derived for it. + type: selected ? { definition: selected.definition, value: selected.value } : data.suggestedType, + }) + onClose() + } + + return ( + + { + event.preventDefault() + handleCancel() + }} + onPointerDownOutside={(event) => { + event.preventDefault() + handleCancel() + }} + className='w-[420px] select-none flex-col gap-4 px-8 py-6' + > + New variable +

+ The {data.pinType.toUpperCase()} pin accepts more than one data type, so + the editor cannot pick one for you. Confirm the variable before it is created. +

+ +
+
+ + setName(event.target.value)} + onKeyDown={(event) => event.key === 'Enter' && handleConfirm()} + className={inputClass} + autoFocus + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ ) +} + +export { CreateGraphicalVariableModal } diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index 1f696ed8f..6732d5257 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -3,6 +3,7 @@ import { ComponentPropsWithoutRef, ReactNode, useCallback, useEffect, useState } import { useCapabilities, useProject, useSystem, useTheme } from '../../../middleware/shared/providers' import { useOpenPLCStore } from '../../store' import type { RungLadderState } from '../../store/slices/ladder' +import type { CreateGraphicalVariableModalData } from '../../store/slices/modal/types' import { cn } from '../../utils/cn' import { ResolutionWarning } from '../_atoms/resolution-warning-message' import Toaster from '../_features/[app]/toast/toaster' @@ -13,6 +14,7 @@ import { RuntimeCreateUserModal, RuntimeDiscoverDevicesModal, RuntimeLoginModal import { ConfirmDeleteProjectModal } from '../_organisms/modals/confirm-delete-project-modal' import { ConfirmInstallLibrariesModal } from '../_organisms/modals/confirm-install-libraries-modal' import { ConfirmPlcopenImportModal } from '../_organisms/modals/confirm-plcopen-import-modal' +import { CreateGraphicalVariableModal } from '../_organisms/modals/create-graphical-variable-modal' import { DebuggerMessageModal } from '../_organisms/modals/debugger-message-modal' import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' @@ -35,6 +37,8 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { const caps = useCapabilities() const [showComponent, setShowComponent] = useState(true) const modals = useOpenPLCStore(useCallback((s) => s.modals, [])) + const dataTypes = useOpenPLCStore(useCallback((s) => s.project.data.dataTypes, [])) + const { closeModal, onOpenChange } = useOpenPLCStore(useCallback((s) => s.modalActions, [])) const OS = useOpenPLCStore(useCallback((s) => s.workspace.systemConfigs.OS, [])) const { setSystemConfigs, setRecent } = useOpenPLCStore(useCallback((s) => s.workspaceActions, [])) @@ -130,6 +134,15 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['confirm-plcopen-import']?.open === true && ( )} + {modals?.['create-graphical-variable']?.open === true && ( + dataType.name)} + onOpenChange={(open) => onOpenChange('create-graphical-variable', open)} + onClose={closeModal} + /> + )} {modals?.['quit-application']?.open === true && ( )} diff --git a/src/frontend/services/graphical-scope.ts b/src/frontend/services/graphical-scope.ts index 1c972717e..e38f2270d 100644 --- a/src/frontend/services/graphical-scope.ts +++ b/src/frontend/services/graphical-scope.ts @@ -20,7 +20,12 @@ */ import type { PLCVariable } from '../../middleware/shared/ports/types' -import { getVariableRestrictionType, validateVariableType } from '../utils/PLC/validate-variable-type' +import type { BoundBlockPin } from '../utils/PLC/validate-variable-type' +import { + getVariableRestrictionType, + resolveNewVariableType, + validateVariableType, +} from '../utils/PLC/validate-variable-type' import { getScopedQueryApi } from './st-lsp' /** @@ -171,19 +176,25 @@ export async function isExpressionValidForType( return validateVariableType(result.type, expectedType).isValid } -/** Concrete `{definition, value}` to type a brand-new variable created from a box's expected type. */ -export function newVariableTypeForExpected(expectedType: string | undefined): { +/** + * Concrete `{definition, value}` to type a brand-new variable created from a + * box's expected type. `boundSiblings` are the pins of the same block instance + * that already have a variable on them — a generic pin (`ANY`, `ANY_NUM`, …) + * adopts the concrete type the block resolved to instead of guessing (#479). + * Decision logic lives in {@link resolveNewVariableType} so it stays testable. + */ +export function newVariableTypeForExpected( + expectedType: string | undefined, + boundSiblings: BoundBlockPin[] = [], +): { definition: PLCVariable['type']['definition'] value: string } { - if (!expectedType) return { definition: 'base-type', value: 'dint' } - const restriction = getVariableRestrictionType(expectedType) - const value = restriction.values - ? Array.isArray(restriction.values) - ? restriction.values[0] - : restriction.values - : 'dint' - return { definition: (restriction.definition as PLCVariable['type']['definition']) ?? 'base-type', value } + const resolved = resolveNewVariableType(expectedType, boundSiblings) + return { + definition: (resolved.definition as PLCVariable['type']['definition']) ?? 'base-type', + value: resolved.value, + } } /** Build the variable reference a graphical node stores when an LSP completion is chosen. */ diff --git a/src/frontend/store/slices/modal/slice.ts b/src/frontend/store/slices/modal/slice.ts index cecff18e4..00567eb2d 100644 --- a/src/frontend/store/slices/modal/slice.ts +++ b/src/frontend/store/slices/modal/slice.ts @@ -28,6 +28,7 @@ const ALL_MODAL_TYPES: ModalTypes[] = [ 'confirm-install-libraries', 'project-readme', 'confirm-plcopen-import', + 'create-graphical-variable', ] function createDefaultModals() { diff --git a/src/frontend/store/slices/modal/types.ts b/src/frontend/store/slices/modal/types.ts index 84a34b138..018cac4e2 100644 --- a/src/frontend/store/slices/modal/types.ts +++ b/src/frontend/store/slices/modal/types.ts @@ -1,3 +1,5 @@ +import type { PLCVariable, VariableClass } from '../../../../middleware/shared/ports/types' + // --------------------------------------------------------------------------- // Modal types — superset of both editor and web // --------------------------------------------------------------------------- @@ -42,6 +44,41 @@ export type ModalTypes = * item. Acts on whatever project is currently open — no targeted * data payload (unlike `confirm-delete-project`). */ | 'confirm-plcopen-import' + /** "Add variable" on a GENERIC block pin (`ANY`, `ANY_NUM`, …) in a + * graphical editor: the pin doesn't dictate a type, so the user + * confirms name / class / type instead of the editor guessing + * (issue #479). Data shape: `CreateGraphicalVariableModalData` — + * carries the pin type, the suggested values and an `onConfirm` + * the caller uses to create + bind the variable. */ + | 'create-graphical-variable' + +/** + * Payload of the `create-graphical-variable` modal. Lives here so the graphical + * editors (`_atoms`) and the modal (`_organisms`) share one contract without + * importing across the atomic-design grain. + * + * `onConfirm` keeps creation + node binding with the caller: each editor + * already knows how to bind a variable into its own graph. + */ +export type CreateGraphicalVariableModalData = { + /** Declared type of the pin the box sits on (e.g. `ANY`, `ANY_NUM`). */ + pinType: string + /** Name the user typed into the box. */ + name: string + /** Type the editor inferred from the block's bound pins — the pre-selection. */ + suggestedType: { definition: PLCVariable['type']['definition']; value: string } + onConfirm: (choice: { + name: string + class: VariableClass + type: { definition: PLCVariable['type']['definition']; value: string } + }) => void + /** + * Undo the box's provisional state. Opening the dialog blurs the box, which + * binds the typed text as a raw (unresolved) reference — abandoning the + * dialog must not leave that behind. + */ + onCancel?: () => void +} export type ModalsState = Record diff --git a/src/frontend/utils/PLC/__tests__/validate-variable-type.test.ts b/src/frontend/utils/PLC/__tests__/validate-variable-type.test.ts index a609e96de..4bfef9ce5 100644 --- a/src/frontend/utils/PLC/__tests__/validate-variable-type.test.ts +++ b/src/frontend/utils/PLC/__tests__/validate-variable-type.test.ts @@ -1,4 +1,9 @@ -import { getVariableRestrictionType, validateVariableType } from '../validate-variable-type' +import { + getVariableRestrictionType, + isGenericTypeName, + resolveNewVariableType, + validateVariableType, +} from '../validate-variable-type' describe('validateVariableType', () => { it('accepts anything for the bare ANY generic', () => { @@ -128,6 +133,10 @@ describe('getVariableRestrictionType', () => { expect(restriction.values).toContain('TIME') }) + it('keeps the single-entry shape for a singleton generic', () => { + expect(getVariableRestrictionType('ANY_STRING')).toEqual({ values: ['STRING'], definition: 'base-type' }) + }) + it('returns the concrete base-type name uppercased', () => { expect(getVariableRestrictionType('BOOL')).toEqual({ values: 'BOOL', definition: 'base-type' }) }) @@ -139,3 +148,103 @@ describe('getVariableRestrictionType', () => { }) }) }) + +describe('isGenericTypeName', () => { + it('recognises ANY and every ANY_* family, case-insensitively', () => { + expect(isGenericTypeName('ANY')).toBe(true) + expect(isGenericTypeName('any')).toBe(true) + expect(isGenericTypeName('ANY_NUM')).toBe(true) + expect(isGenericTypeName('any_int')).toBe(true) + expect(isGenericTypeName('ANY_ELEMENTARY')).toBe(true) + }) + + it('rejects concrete and user-defined type names', () => { + expect(isGenericTypeName('INT')).toBe(false) + expect(isGenericTypeName('BOOL')).toBe(false) + expect(isGenericTypeName('TIME')).toBe(false) + expect(isGenericTypeName('MyStruct')).toBe(false) + expect(isGenericTypeName('')).toBe(false) + }) +}) + +describe('resolveNewVariableType', () => { + it('falls back to DINT when the box is not wired to any pin', () => { + expect(resolveNewVariableType(undefined)).toEqual({ definition: 'base-type', value: 'dint' }) + }) + + it('mirrors a concrete pin type, ignoring any bound siblings', () => { + expect(resolveNewVariableType('TIME')).toEqual({ definition: 'base-type', value: 'TIME' }) + expect(resolveNewVariableType('BOOL', [{ pinType: 'ANY', variableType: 'REAL' }])).toEqual({ + definition: 'base-type', + value: 'BOOL', + }) + }) + + it('keeps a derived (user-defined) pin type as-is', () => { + expect(resolveNewVariableType('MyStruct')).toEqual({ definition: 'derived', value: 'MyStruct' }) + }) + + describe('generic pins (issue #479)', () => { + it('adopts the type already bound to another generic pin of the same block', () => { + // MOVE: IN : ANY bound to an INT, so OUT : ANY must be an INT too. + expect(resolveNewVariableType('ANY', [{ pinType: 'ANY', variableType: 'INT' }])).toEqual({ + definition: 'base-type', + value: 'INT', + }) + // ADD: IN1 : ANY_NUM bound to a REAL — used to create a SINT. + expect(resolveNewVariableType('ANY_NUM', [{ pinType: 'ANY_NUM', variableType: 'REAL' }])).toEqual({ + definition: 'base-type', + value: 'REAL', + }) + }) + + it('accepts a user-defined type bound to an ANY pin', () => { + expect(resolveNewVariableType('ANY', [{ pinType: 'ANY', variableType: 'MyStruct' }])).toEqual({ + definition: 'derived', + value: 'MyStruct', + }) + }) + + it('ignores siblings sitting on concrete pins', () => { + // MOVE's EN : BOOL says nothing about how the ANY pins resolved. + expect(resolveNewVariableType('ANY_NUM', [{ pinType: 'BOOL', variableType: 'BOOL' }])).toEqual({ + definition: 'base-type', + value: 'DINT', + }) + }) + + it('ignores siblings whose type the pin would reject', () => { + expect(resolveNewVariableType('ANY_INT', [{ pinType: 'ANY_NUM', variableType: 'REAL' }])).toEqual({ + definition: 'base-type', + value: 'DINT', + }) + }) + + it('ignores siblings with no bound type, or a still-generic one', () => { + expect( + resolveNewVariableType('ANY_INT', [ + { pinType: 'ANY_INT', variableType: '' }, + { pinType: 'ANY_INT', variableType: 'ANY_INT' }, + { pinType: 'ANY_INT', variableType: 'LINT' }, + ]), + ).toEqual({ definition: 'base-type', value: 'LINT' }) + }) + + it('prefers DINT over the first flattened entry when there is nothing to infer from', () => { + // The flattened ANY_NUM/ANY_INT sets start at SINT — the old default. + expect(resolveNewVariableType('ANY_NUM')).toEqual({ definition: 'base-type', value: 'DINT' }) + expect(resolveNewVariableType('ANY_INT')).toEqual({ definition: 'base-type', value: 'DINT' }) + expect(resolveNewVariableType('ANY')).toEqual({ definition: 'base-type', value: 'DINT' }) + }) + + it('honours the restriction when DINT is not in it', () => { + expect(resolveNewVariableType('ANY_BIT')).toEqual({ definition: 'base-type', value: 'BOOL' }) + expect(resolveNewVariableType('ANY_REAL')).toEqual({ definition: 'base-type', value: 'REAL' }) + expect(resolveNewVariableType('ANY_STRING')).toEqual({ definition: 'base-type', value: 'STRING' }) + }) + + it('falls back to DINT for an unknown generic name', () => { + expect(resolveNewVariableType('ANY_NOT_A_GENERIC')).toEqual({ definition: 'base-type', value: 'dint' }) + }) + }) +}) diff --git a/src/frontend/utils/PLC/validate-variable-type.ts b/src/frontend/utils/PLC/validate-variable-type.ts index f59c4aec4..fdf2d143d 100644 --- a/src/frontend/utils/PLC/validate-variable-type.ts +++ b/src/frontend/utils/PLC/validate-variable-type.ts @@ -151,3 +151,80 @@ export const getVariableRestrictionType = (variableType: string) => { definition: isABaseType.success ? 'base-type' : 'derived', } } + +/** + * A pin of the same block instance that already has a variable bound to it: + * the pin's DECLARED type (`pinType`, possibly generic) plus the CONCRETE type + * of the variable sitting on it (`variableType`). + */ +export type BoundBlockPin = { + pinType: string + variableType: string +} + +/** + * True for a generic pin type — `ANY` or any `ANY_*` family. Such a pin has no + * type of its own: IEC resolves it from the block's other pins, so it is the + * one case where the editor cannot type a new variable on its own. + * Case-insensitive. + */ +export const isGenericTypeName = (typeName: string): boolean => { + const upper = typeName.toUpperCase() + return upper === 'ANY' || upper.includes('ANY_') +} + +/** Canonical `{definition, value}` for a concrete type name, via the restriction table. */ +const newTypeFromConcrete = (concreteType: string): { definition: string | undefined; value: string } => { + const restriction = getVariableRestrictionType(concreteType) + // Concrete names always come back as a single string — the array shape is + // reserved for `ANY_*` inputs, which never reach here. + const value = Array.isArray(restriction.values) ? restriction.values[0] : restriction.values + return { definition: restriction.definition, value: value ?? 'dint' } +} + +/** + * The `{definition, value}` to type a brand-new variable created from a box's + * expected type. + * + * IEC 61131-3 resolves every generic pin (`ANY`, `ANY_NUM`, …) of one block + * instance to the SAME concrete type. `validateVariableType` only judges a pin + * in isolation — `ANY` accepts anything and `SINT` is a legal `ANY_NUM` — so + * typing a new variable from its own pin alone silently produced a type the + * transpiler then rejects (issue #479: a MOVE fed by an `INT` created a `DINT` + * sink). Feeding the pins already bound on the same block instance lets a + * generic pin adopt the type the block has actually resolved to. + * + * Only siblings sitting on GENERIC pins count: a concrete pin (MOVE's + * `EN : BOOL`) says nothing about how the generic ones resolved. + */ +export const resolveNewVariableType = ( + expectedType: string | undefined, + boundSiblings: BoundBlockPin[] = [], +): { definition: string | undefined; value: string } => { + // Box not wired to any pin — nothing constrains it. + if (!expectedType) return { definition: 'base-type', value: 'dint' } + + const upperExpectedType = expectedType.toUpperCase() + if (!isGenericTypeName(upperExpectedType)) return newTypeFromConcrete(expectedType) + + const inferred = boundSiblings.find( + (sibling) => + isGenericTypeName(sibling.pinType) && + sibling.variableType.length > 0 && + !isGenericTypeName(sibling.variableType) && + validateVariableType(sibling.variableType, upperExpectedType).isValid, + ) + if (inferred) return newTypeFromConcrete(inferred.variableType) + + // Nothing bound yet, so nothing to infer from (first variable on a fresh + // block). Pick a default that at least satisfies the restriction, preferring + // DINT — the IEC default integer, and already what a plain `ANY` fell back to + // — over the first entry of the flattened set, which is SINT for every + // numeric generic. + const flattened = flattenGenericToBaseTypes(upperExpectedType) + if (flattened.includes('DINT')) return { definition: 'base-type', value: 'DINT' } + if (flattened.length > 0) return { definition: 'base-type', value: flattened[0] } + // Unknown generic name (a malformed block definition) — no restriction to + // honour, fall back to the unconstrained default. + return { definition: 'base-type', value: 'dint' } +} From c2b14ab23d7218daa1bb348d9ac211cbc9892fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 21:51:59 -0300 Subject: [PATCH 38/79] fix(datatypes): flag undo/redo divergence dirty and batch initial-value history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undo/redo now marks the file and workspace unsaved whenever history does not land on the saved depth — previously an undo away from the saved state kept the file flagged saved while the store diverged from disk, so the next save-all silently skipped the revert. The array initial-value input re-syncs from the store on external changes (undo/redo) instead of keeping its mount-time value, and captures one history entry per typing burst (rearmed on blur or external change) rather than one per keystroke. Own writes are tracked in a ref because the data prop lags one render behind the store. The enumerated initial value gets the same store re-sync. DOPE-534 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LCcPrjCcCF29NQbG8xbwA --- .../_molecules/data-types/array/index.tsx | 30 +++++++++++++++---- .../data-types/enumerated/index.tsx | 3 +- .../store/__tests__/shared-slice.test.ts | 27 +++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 6 ++++ 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/frontend/components/_molecules/data-types/array/index.tsx b/src/frontend/components/_molecules/data-types/array/index.tsx index a09052535..fb70e63da 100644 --- a/src/frontend/components/_molecules/data-types/array/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/index.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, ComponentPropsWithoutRef, useEffect, useState } from 'react' +import { ChangeEvent, ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react' import { baseTypeEnum } from '../../../../../middleware/shared/ports/plc-schemas' import type { PLCDataType } from '../../../../../middleware/shared/ports/types' @@ -73,7 +73,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const ROWS_NOT_SELECTED = -1 const [arrayTable, setArrayTable] = useState<{ selectedRow: number }>({ selectedRow: ROWS_NOT_SELECTED }) - const [initialValueData, setInitialValueData] = useState('') + const [initialValueData, setInitialValueData] = useState(data.initialValue || '') const [baseType, setBaseType] = useState(data.baseType.value) const [tableData, setTableData] = useState([]) @@ -82,10 +82,21 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { setTableData(data.dimensions) }, [data.dimensions]) + // One history entry per typing burst: armed on the first keystroke, + // rearmed on blur or when the store value changes under us (undo/redo). + const initialValueCaptured = useRef(false) + // `data` lags one render behind the store (the parent copies it via effect), + // so compare against our own last write to spot genuinely external changes. + const lastWrittenInitialValue = useRef(data.initialValue || '') + useEffect(() => { - setInitialValueData(data.initialValue || '') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const storeValue = data.initialValue || '' + if (storeValue !== lastWrittenInitialValue.current) { + setInitialValueData(storeValue) + lastWrittenInitialValue.current = storeValue + initialValueCaptured.current = false + } + }, [data.initialValue]) useEffect(() => { setBaseType(data.baseType.value) @@ -93,7 +104,11 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const handleInitialValueChange = (e: ChangeEvent) => { setInitialValueData(e.target.value) - captureAndPush(editor.meta.name) + lastWrittenInitialValue.current = e.target.value + if (!initialValueCaptured.current) { + captureAndPush(editor.meta.name) + initialValueCaptured.current = true + } const updatedData = { ...data } updatedData.initialValue = e.target.value updateDatatype(data.name, updatedData as PLCArrayDatatype) @@ -214,6 +229,9 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { { + initialValueCaptured.current = false + }} value={initialValueData} className='flex h-7 w-full max-w-44 items-center justify-between gap-2 rounded-lg border border-neutral-400 bg-white px-3 py-2 font-caption text-xs font-normal text-neutral-950 focus-within:border-brand focus:border-brand focus:outline-none dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100' /> diff --git a/src/frontend/components/_molecules/data-types/enumerated/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/index.tsx index 44a7f5a92..aec948fd9 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/index.tsx @@ -33,8 +33,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { useEffect(() => { setInitialValueData(data.initialValue || '') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [data.initialValue]) useEffect(() => { setTableData(data.values) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 4475d53a6..f6c9536c4 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1521,6 +1521,33 @@ describe('createSharedSlice', () => { expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(true) }) + it('undo marks the data type file unsaved when history diverges from the saved depth', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [edited] }) + store.getState().snapshotActions.markSaved('Colors') + store.getState().fileActions.updateFile({ name: 'Colors', saved: true }) + store.getState().workspaceActions.setEditingState('saved') + + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + + it('redo marks the data type file unsaved when history diverges from the saved depth', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.undo('Colors') + store.getState().fileActions.updateFile({ name: 'Colors', saved: true }) + store.getState().workspaceActions.setEditingState('saved') + + store.getState().snapshotActions.redo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + it('undo leaves the data type untouched when the snapshot has no dataTypes entry', () => { store.getState().projectActions.updateDatatype('Colors', edited) store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null }) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 00b78eb43..ee0d6aa4b 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1059,6 +1059,9 @@ const createSharedSlice: StateCreator = (s const afterUndo = getState().undoRedo[pouName] if (afterUndo?.savedAtDepth !== null && afterUndo?.savedAtDepth === afterUndo?.past.length) { getState().fileActions.updateFile({ name: pouName, saved: true }) + } else { + // Diverged from the on-disk state — flag it or the next save-all skips the revert. + getState().sharedWorkspaceActions.handleFileAndWorkspaceSavedState(pouName) } return true }, @@ -1127,6 +1130,9 @@ const createSharedSlice: StateCreator = (s const afterRedo = getState().undoRedo[pouName] if (afterRedo?.savedAtDepth !== null && afterRedo?.savedAtDepth === afterRedo?.past.length) { getState().fileActions.updateFile({ name: pouName, saved: true }) + } else { + // Diverged from the on-disk state — flag it or the next save-all skips the revert. + getState().sharedWorkspaceActions.handleFileAndWorkspaceSavedState(pouName) } return true }, From 2106e536e62302919a090f37ac1ed505e146ec59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 22:31:15 -0300 Subject: [PATCH 39/79] feat(datatypes): switch datatype persistence to datatypes/*.dt with legacy migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind the new isDataTypeFilesEnabled() compile-time flag (off by default — flag-off builds behave byte-identically to the legacy project.json format): full saves serialize every data type to its own datatypes/.dt file and write dataTypes: [] into project.json; single-tab saves write the type's own file instead of the whole project.json; deletes and renames queue the .dt path for deletion (new updateDatatypeName action); "Don't save" reloads the type from its file. Loading prefers .dt files over the legacy JSON field (auto migration on first save), and files that fail to parse are preserved raw and echoed back verbatim by every save so nothing is silently dropped. Struct-field documentation now survives the zod schema, and data type / struct field names are validated with the full isLegalIdentifier rule (keywords, literals, identifier shape) at the form and in the .dt parser. DOPE-533 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019f6Kfu7zebQC3UdeN29tiq --- src/backend/shared/types/PLC/open-plc.ts | 3 + .../__tests__/parse-project-files.test.ts | 86 ++++++++++++++++++ .../shared/utils/parse-project-files.ts | 36 +++++++- .../structure/table/editable-cell.tsx | 9 ++ src/frontend/services/save-actions.ts | 88 ++++++++++++++++--- .../store/__tests__/project-slice.test.ts | 39 ++++++++ .../store/__tests__/shared-slice.test.ts | 5 ++ src/frontend/store/slices/project/slice.ts | 25 ++++++ src/frontend/store/slices/project/types.ts | 11 +++ src/frontend/store/slices/shared/slice.ts | 10 ++- src/frontend/store/slices/shared/types.ts | 4 + .../__tests__/data-type-text-parser.test.ts | 7 ++ .../utils/PLC/data-type-text-parser.ts | 14 ++- .../utils/__tests__/feature-flags.test.ts | 11 +++ src/frontend/utils/feature-flags.ts | 18 ++++ .../adapters/editor/project-adapter.ts | 7 ++ src/middleware/shared/ports/project-port.ts | 3 + 17 files changed, 358 insertions(+), 18 deletions(-) create mode 100644 src/frontend/utils/__tests__/feature-flags.test.ts create mode 100644 src/frontend/utils/feature-flags.ts diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 2832fe46e..ab36973ea 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -85,6 +85,9 @@ const PLCStructureVariableSchema = z.object({ }), }) .optional(), + // Preserved through load — the .dt serializer emits it as a + // trailing (* … *) comment; omitting it here strips it on open. + documentation: z.string().optional(), }) const PLCStructureDatatypeSchema = z.object({ name: z.string(), diff --git a/src/backend/shared/utils/__tests__/parse-project-files.test.ts b/src/backend/shared/utils/__tests__/parse-project-files.test.ts index 929a0f8b0..98c11e93e 100644 --- a/src/backend/shared/utils/__tests__/parse-project-files.test.ts +++ b/src/backend/shared/utils/__tests__/parse-project-files.test.ts @@ -823,3 +823,89 @@ describe('parseProjectFiles — POU name derivation', () => { expect(result.projectData.pous[0].name).toBe('Derived') }) }) + +// --------------------------------------------------------------------------- +// Data type files (datatypes/.dt) — hydration matrix (DOPE-533) +// --------------------------------------------------------------------------- + +describe('data type file hydration', () => { + const legacyEnum = { + name: 'Color', + derivation: 'enumerated', + initialValue: '', + values: [{ description: 'Red' }, { description: 'Green' }], + } + + const parse = (dataTypeFiles: RawProjectFile[], jsonDataTypes: unknown[] = []) => + parseProjectFiles( + '/p', + makeProjectJson({ dataTypes: jsonDataTypes }), + makeDeviceConfig(), + makePinMapping(), + [], + [], + [], + '', + dataTypeFiles, + ) + + it('parses .dt files into dataTypes (files win over legacy JSON)', () => { + const result = parse( + [{ relativePath: 'datatypes/Mode.dt', content: 'TYPE\n Mode : (Auto, Manual);\nEND_TYPE\n' }], + [legacyEnum], + ) + expect(result.projectData.dataTypes).toEqual([ + { + name: 'Mode', + derivation: 'enumerated', + values: [{ description: 'Auto' }, { description: 'Manual' }], + initialValue: '', + }, + ]) + expect(result.warnings).toBeUndefined() + expect(result.unparsedDataTypeFiles).toBeUndefined() + }) + + it('falls back to legacy project.json dataTypes when no .dt files exist', () => { + const result = parse([], [legacyEnum]) + expect(result.projectData.dataTypes).toEqual([legacyEnum]) + }) + + it('yields an empty list when neither files nor legacy JSON carry types', () => { + const result = parse([], []) + expect(result.projectData.dataTypes).toEqual([]) + }) + + it('preserves unparseable .dt files raw with a warning instead of dropping them', () => { + const broken = { relativePath: 'datatypes/Broken.dt', content: 'TYPE\n Broken : ???;\nEND_TYPE\n' } + const result = parse([broken, { relativePath: 'datatypes/Ok.dt', content: 'TYPE\n Ok : (A);\nEND_TYPE\n' }]) + expect(result.projectData.dataTypes.map((d) => d.name)).toEqual(['Ok']) + expect(result.unparsedDataTypeFiles).toEqual([broken]) + expect(result.warnings?.some((w) => w.includes('datatypes/Broken.dt'))).toBe(true) + }) + + it('treats a declared-name/file-name mismatch as unparseable (raw preserved)', () => { + const mismatched = { relativePath: 'datatypes/Alpha.dt', content: 'TYPE\n Beta : (A);\nEND_TYPE\n' } + const result = parse([mismatched]) + expect(result.projectData.dataTypes).toEqual([]) + expect(result.unparsedDataTypeFiles).toEqual([mismatched]) + expect(result.warnings?.some((w) => w.includes('does not match'))).toBe(true) + }) + + it('keeps struct field documentation through schema validation (legacy JSON)', () => { + const structWithDoc = { + name: 'Motor', + derivation: 'structure', + variable: [ + { + name: 'speed', + type: { definition: 'base-type', value: 'INT' }, + initialValue: { simpleValue: { value: '' } }, + documentation: 'target speed in rpm', + }, + ], + } + const result = parse([], [structWithDoc]) + expect(result.projectData.dataTypes).toEqual([structWithDoc]) + }) +}) diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index 0564d583d..a242d539f 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -8,6 +8,7 @@ * The backend only reads raw files from disk; all parsing happens here. */ +import { parseDataTypeFromText } from '../../../frontend/utils/PLC/data-type-text-parser' import { detectLanguageFromExtension, findLastEndVarIndex, @@ -79,6 +80,11 @@ export interface ParsedProjectData { devicePinMapping?: DevicePin[] | Record /** Warnings collected during parsing (e.g. dropped files that failed validation). */ warnings?: string[] + /** `datatypes/*.dt` files that failed to parse (or whose declared + * name mismatched the file name). Preserved raw so the save flow + * can echo them back verbatim — an unreadable file must never be + * silently dropped from disk. */ + unparsedDataTypeFiles?: RawProjectFile[] } // --------------------------------------------------------------------------- @@ -387,6 +393,9 @@ function deduplicatePouFiles(pouFiles: RawProjectFile[]): RawProjectFile[] { * @param pouFiles - Raw POU files (.st, .il, .ld, .fbd, .py, .cpp, .json) * @param serverFiles - Raw server config files from devices/servers/ * @param remoteDeviceFiles - Raw remote device config files from devices/remote/ + * @param libraryManifest - Raw content of library.json (library projects) + * @param dataTypeFiles - Raw datatypes/*.dt files; when any are present they + * win over the legacy `project.json` `data.dataTypes` field */ export function parseProjectFiles( projectPath: string, @@ -397,6 +406,7 @@ export function parseProjectFiles( serverFiles: RawProjectFile[], remoteDeviceFiles: RawProjectFile[], libraryManifest: string = '', + dataTypeFiles: RawProjectFile[] = [], ): ParsedProjectData { const warnings: string[] = [] @@ -525,6 +535,26 @@ export function parseProjectFiles( } } + // Parse data type files (datatypes/.dt). Own loop — never + // through parsePouFile (pou-path detection throws for datatypes/). + // The declared name must match the file name; a mismatch or any + // parse failure preserves the raw file so the save flow writes it + // back verbatim instead of silently dropping it from disk. + const dataTypesFromFiles: PLCDataType[] = [] + const unparsedDataTypeFiles: RawProjectFile[] = [] + for (const file of dataTypeFiles) { + const expectedName = getBaseNameFromPath(file.relativePath) + const result = parseDataTypeFromText(file.content, expectedName) + if (result.dataType) { + dataTypesFromFiles.push(result.dataType) + } else { + warnings.push( + `Data type file "${file.relativePath}" could not be parsed and was preserved as-is: ${result.error ?? 'unknown error'}`, + ) + unparsedDataTypeFiles.push(file) + } + } + // Extract project data fields const data = project.data ?? {} const configuration = (data.configuration ?? @@ -552,7 +582,10 @@ export function parseProjectFiles( return { meta, projectData: { - dataTypes: (data.dataTypes as PLCDataType[]) ?? [], + // Migration rule: any .dt file present ⇒ the files are the + // source of truth; the legacy JSON field is only the fallback + // for projects that predate the format. + dataTypes: dataTypeFiles.length > 0 ? dataTypesFromFiles : ((data.dataTypes as PLCDataType[]) ?? []), pous, configurations: configuration, servers: servers.length > 0 ? servers : ((data.servers as PLCServer[]) ?? []), @@ -574,5 +607,6 @@ export function parseProjectFiles( deviceConfiguration, devicePinMapping, warnings: warnings.length > 0 ? warnings : undefined, + ...(unparsedDataTypeFiles.length > 0 ? { unparsedDataTypeFiles } : {}), } } diff --git a/src/frontend/components/_molecules/data-types/structure/table/editable-cell.tsx b/src/frontend/components/_molecules/data-types/structure/table/editable-cell.tsx index 1f1aba429..d60f57808 100644 --- a/src/frontend/components/_molecules/data-types/structure/table/editable-cell.tsx +++ b/src/frontend/components/_molecules/data-types/structure/table/editable-cell.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import type { PLCStructureVariable } from '../../../../../../middleware/shared/ports/types' import type { ProjectResponse } from '../../../../../store/slices/project/types' import { cn } from '../../../../../utils/cn' +import { isLegalIdentifier } from '../../../../../utils/keywords' import { InputWithRef } from '../../../../_atoms/input' import { useToast } from '../../../../_features/[app]/toast/use-toast' @@ -32,6 +33,14 @@ const EditableNameCell = ({ getValue, row: { index }, column: { id }, table }: I return { valid: false, message: 'The name cannot be empty' } } + // Field names are written into datatypes/.dt as ST text — + // an illegal identifier here would serialize to a file that + // can't be read back (same rule the .dt parser enforces). + const [legal, reason] = isLegalIdentifier(name) + if (!legal) { + return { valid: false, message: `'${name}' ${reason}` } + } + if (name !== currentName && existingVariables.includes(name)) { return { valid: false, message: `The name '${name}' already exists` } } diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 3a94737ef..1e24bb536 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -13,14 +13,17 @@ import type { PlatformCapabilities } from '../../middleware/shared/ports/platform-capabilities' import type { ProjectPort, RawProjectFile, WriteProjectFiles } from '../../middleware/shared/ports/project-port' -import type { PLCPou } from '../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou } from '../../middleware/shared/ports/types' import { openPLCStoreBase } from '../store' import type { LadderFlowType } from '../store/slices/ladder' import { flushFlowWriteBacks } from '../store/slices/shared/flow-writeback' +import { isDataTypeFilesEnabled } from '../utils/feature-flags' import { parseIecStringToVariables } from '../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../utils/generate-iec-variables-to-string' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../utils/graphical/sync-nodes-with-variables' import { notifyNoWritePermission } from '../utils/notify-no-write-permission' +import { serializeDataTypeToText } from '../utils/PLC/data-type-serializer' +import { parseDataTypeFromText } from '../utils/PLC/data-type-text-parser' import { getExtensionFromLanguage, getFolderFromPouType } from '../utils/PLC/pou-file-extensions' import { parseGraphicalPouFromString, parseTextualPouFromString } from '../utils/PLC/pou-text-parser' import { serializePouToText } from '../utils/PLC/pou-text-serializer' @@ -46,6 +49,7 @@ type ProjectFileCategory = | 'pin-mapping' | 'project-json' | 'library-manifest' + | 'data-type' type ProjectFileSpec = { path: string @@ -71,7 +75,9 @@ function buildProjectJsonContent(state: StoreState): string { { meta: { name: project.meta.name, type: metaType }, data: { - dataTypes: project.data.dataTypes, + // With .dt persistence on, types live in their own files and + // project.json stops carrying them (same shape as `pous`). + dataTypes: isDataTypeFilesEnabled() ? [] : project.data.dataTypes, pous: [], configuration: project.data.configurations, libraries, @@ -95,6 +101,14 @@ function buildPouSpec(pou: PLCPou, state: StoreState): ProjectFileSpec { } } +function buildDataTypeSpec(dt: PLCDataType): ProjectFileSpec { + return { + path: `datatypes/${dt.name}.dt`, + content: serializeDataTypeToText(dt), + category: 'data-type', + } +} + /** * Yield every file the save flow uploads, in a deterministic order, with the * canonical serialized content for each. Used by `buildAllProjectFileContents*` @@ -124,6 +138,17 @@ function* iterateProjectFiles(state: StoreState): Generator { yield buildPouSpec(pou, state) } + if (isDataTypeFilesEnabled()) { + for (const dt of project.data.dataTypes) { + yield buildDataTypeSpec(dt) + } + // Raw .dt files that failed to parse on load — echoed verbatim + // so an unreadable file is never silently dropped from disk. + for (const f of state.unparsedDataTypeFiles) { + yield { path: f.relativePath, content: f.content, category: 'data-type' } + } + } + if (!isLibrary) { for (const s of project.data.servers ?? []) { yield { @@ -254,7 +279,12 @@ function serializeProjectFile( return [{ path: 'library.json', content, category: 'library-manifest' }] } - // data-type, resource: live in project.json + if (file.type === 'data-type' && isDataTypeFilesEnabled()) { + const dt = project.data.dataTypes.find((d) => d.name === fileName) + return dt ? [buildDataTypeSpec(dt)] : [] + } + + // resource (and data-type while the .dt flag is off): live in project.json return [{ path: 'project.json', content: buildProjectJsonContent(state), category: 'project-json' }] } @@ -368,6 +398,7 @@ export async function executeSaveProject( const pouFiles: RawProjectFile[] = [] const serverFiles: RawProjectFile[] = [] const remoteDeviceFiles: RawProjectFile[] = [] + const dataTypeFiles: RawProjectFile[] = [] let projectJson = '' // `deviceConfig` / `pinMapping` / `libraryManifest` stay // `undefined` when the iterator doesn't yield them. Library @@ -392,6 +423,9 @@ export async function executeSaveProject( case 'remote-device': remoteDeviceFiles.push({ relativePath: spec.path, content }) break + case 'data-type': + dataTypeFiles.push({ relativePath: spec.path, content }) + break case 'device-config': deviceConfig = content break @@ -416,8 +450,7 @@ export async function executeSaveProject( pouFiles, serverFiles, remoteDeviceFiles, - // Populated when the .dt write path is switched on (DOPE-533). - dataTypeFiles: [], + dataTypeFiles, deletions: [...pendingDeletions], } @@ -435,6 +468,7 @@ export async function executeSaveProject( ...pouFiles.map((f) => ({ path: f.relativePath, content: f.content })), ...serverFiles.map((f) => ({ path: f.relativePath, content: f.content })), ...remoteDeviceFiles.map((f) => ({ path: f.relativePath, content: f.content })), + ...dataTypeFiles.map((f) => ({ path: f.relativePath, content: f.content })), ] state.versionControlActions.recordSavedFiles({ saved: savedRecords, @@ -627,12 +661,15 @@ export async function executeSaveFile( if (!spec) return fail('Save failed') const res = await projectPort.saveFile(joinPath(projectPath, 'library.json'), spec.content) if (!res.success) return fail(res.error ?? 'Save failed') + } else if (file.type === 'data-type' && isDataTypeFilesEnabled()) { + const spec = specs[0] + if (!spec) return fail(`Data type "${fileName}" not found.`) + const res = await projectPort.saveFile(joinPath(projectPath, 'datatypes', `${fileName}.dt`), spec.content) + if (!res.success) return fail(res.error ?? 'Save failed') } else { - // data-type, resource: live in project.json (legacy whole-file - // write). These editors don't yet have a surgical save path — - // they still cross-contaminate each other today, which we accept - // until each is migrated to its own read-modify-write branch - // mirroring the library-manager case above. + // resource (and data-type while the .dt flag is off): live in + // project.json (legacy whole-file write) — cross-contamination + // accepted until each is migrated to its own branch. const spec = specs[0] if (!spec) return fail('Save failed') const res = await projectPort.saveFile(joinPath(projectPath, 'project.json'), spec.content) @@ -804,6 +841,34 @@ export async function reloadPouFromDisk(pouName: string, projectPort: ProjectPor } } +/** + * Reload a single data type from its `datatypes/.dt` file, + * discarding in-memory changes ("Don't save" on a datatype tab). + * Same parser the project-open path uses; the name-must-match-file + * rule applies, so a hand-tampered file fails the reload rather than + * silently rekeying the type. + */ +export async function reloadDataTypeFromDisk(name: string, projectPort: ProjectPort): Promise<{ success: boolean }> { + const state = openPLCStoreBase.getState() + const dt = state.project.data.dataTypes.find((d) => d.name === name) + if (!dt) return { success: false } + + try { + const fullPath = joinPath(state.project.meta.path, 'datatypes', `${name}.dt`) + const result = await projectPort.readFileContent(fullPath) + if (!result.success || !result.content) return { success: false } + + const parsed = parseDataTypeFromText(result.content, name) + if (!parsed.dataType) return { success: false } + + state.projectActions.applyDatatypeSnapshot(name, parsed.dataType) + return { success: true } + } catch (error) { + console.error(`Failed to reload data type "${name}" from disk:`, error) + return { success: false } + } +} + /** * Surgical save for the Library Manager tab. * @@ -1078,6 +1143,9 @@ export async function reloadFileFromDisk(fileName: string, projectPort: ProjectP if (file.type === 'library-manifest') { return reloadLibraryManifestFromCleanState(fileName) } + if (file.type === 'data-type' && isDataTypeFilesEnabled()) { + return reloadDataTypeFromDisk(fileName, projectPort) + } // Everything else routes through the POU-specific reload. Non-POU // file types that need a revert path should add a branch above // (mirroring the library-manager / vendor-screen cases) rather diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index c965e69bf..a20a20d53 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -1129,6 +1129,13 @@ describe('createProjectSlice', () => { expect(store.getState().project.data.dataTypes).toHaveLength(0) }) + it('queues the datatypes/.dt path for deletion', () => { + const dt: PLCDataType = { name: 'MyStruct', derivation: 'structure', variable: [] } + store.getState().projectActions.createDatatype({ data: dt }) + store.getState().projectActions.deleteDatatype('MyStruct') + expect(store.getState().pendingDeletions).toContain('datatypes/MyStruct.dt') + }) + it('does nothing when data type not found', () => { const dt: PLCDataType = { name: 'A', derivation: 'structure', variable: [] } store.getState().projectActions.createDatatype({ data: dt }) @@ -1137,6 +1144,38 @@ describe('createProjectSlice', () => { }) }) + describe('updateDatatypeName', () => { + it('renames the data type and queues the old .dt path for deletion', () => { + const dt: PLCDataType = { name: 'OldName', derivation: 'structure', variable: [] } + store.getState().projectActions.createDatatype({ data: dt }) + store.getState().projectActions.updateDatatypeName('OldName', 'NewName') + expect(store.getState().project.data.dataTypes[0].name).toBe('NewName') + expect(store.getState().pendingDeletions).toContain('datatypes/OldName.dt') + }) + + it('does nothing when the data type is not found', () => { + store.getState().projectActions.updateDatatypeName('Ghost', 'NewName') + expect(store.getState().project.data.dataTypes).toHaveLength(0) + expect(store.getState().pendingDeletions).toHaveLength(0) + }) + }) + + describe('setUnparsedDataTypeFiles', () => { + it('replaces the stashed raw .dt files', () => { + const raw = [{ relativePath: 'datatypes/Broken.dt', content: 'TYPE garbage' }] + store.getState().projectActions.setUnparsedDataTypeFiles(raw) + expect(store.getState().unparsedDataTypeFiles).toEqual(raw) + store.getState().projectActions.setUnparsedDataTypeFiles([]) + expect(store.getState().unparsedDataTypeFiles).toEqual([]) + }) + + it('is reset by clearProjects', () => { + store.getState().projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/X.dt', content: 'x' }]) + store.getState().projectActions.clearProjects() + expect(store.getState().unparsedDataTypeFiles).toEqual([]) + }) + }) + describe('updateDatatype', () => { it('replaces data type by name', () => { const dt: PLCDataType = { name: 'MyStruct', derivation: 'structure', variable: [] } diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 87c7f22c0..a0c55d53d 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -554,6 +554,11 @@ describe('createSharedSlice', () => { expect(state.tabs[0].name).toBe('NewDT') }) + it('queues the old datatypes/.dt path for deletion', () => { + store.getState().datatypeActions.rename('OldDT', 'NewDT') + expect(store.getState().pendingDeletions).toContain('datatypes/OldDT.dt') + }) + it('returns error when new name already exists', () => { store.getState().datatypeActions.create({ name: 'Existing', derivation: 'array' }) const result = store.getState().datatypeActions.rename('OldDT', 'Existing') diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index d81e93b31..1ed7b6605 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -517,6 +517,7 @@ const createProjectSlice: StateCreator = }, }, pendingDeletions: [], + unparsedDataTypeFiles: [], iecAliasMemory: {}, projectActions: { @@ -552,6 +553,7 @@ const createProjectSlice: StateCreator = }, } slice.pendingDeletions = [] + slice.unparsedDataTypeFiles = [] // Session alias-memory is per-project; drop it on a fresh slate so // one project's remembered aliases can't leak into the next. slice.iecAliasMemory = {} @@ -1063,6 +1065,10 @@ const createProjectSlice: StateCreator = deleteDatatype: (name) => { setState( produce((slice: ProjectSlice) => { + // Harmless while the file doesn't exist yet (flag off): the + // editor's deletion pass is existence-checked, the web's + // relies on delete-by-omission. + slice.pendingDeletions.push(`datatypes/${name}.dt`) slice.project.data.dataTypes = slice.project.data.dataTypes.filter((d) => d.name !== name) }), ) @@ -1078,6 +1084,18 @@ const createProjectSlice: StateCreator = }), ) }, + updateDatatypeName: (oldName, newName) => { + setState( + produce((slice: ProjectSlice) => { + const dt = slice.project.data.dataTypes.find((d) => d.name === oldName) + if (!dt) return + // Queue the OLD path — the next save serializes the type + // under its new name (model: updatePouName). + slice.pendingDeletions.push(`datatypes/${oldName}.dt`) + dt.name = newName + }), + ) + }, createArrayDimension: ({ name, derivation: _derivation }) => { setState( produce((slice: ProjectSlice) => { @@ -1107,6 +1125,13 @@ const createProjectSlice: StateCreator = }), ) }, + setUnparsedDataTypeFiles: (files) => { + setState( + produce((slice: ProjectSlice) => { + slice.unparsedDataTypeFiles = files + }), + ) + }, // ----------------------------------------------------------------------- // Tasks diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 6c652d245..5a1c1e8c2 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -1,3 +1,4 @@ +import type { RawProjectFile } from '../../../../middleware/shared/ports/project-port' import type { EthercatConfig, ModbusBufferMapping, @@ -216,9 +217,15 @@ export type ProjectActions = { createDatatype: (dto: DataTypeDTO & { rowToInsert?: number }) => ProjectResponse deleteDatatype: (name: string) => void updateDatatype: (name: string, data?: PLCDataType) => void + /** Rename + queue the old `datatypes/.dt` path for deletion + * (model: `updatePouName`). Reference propagation is DOPE-536. */ + updateDatatypeName: (oldName: string, newName: string) => void createArrayDimension: (args: { name: string; derivation: 'array' | 'enumerated' | 'structure' }) => void rearrangeStructureVariables: (args: { associatedDataType?: string; rowId: number; newIndex: number }) => void applyDatatypeSnapshot: (name: string, data: PLCDataType) => void + /** Stash raw `.dt` files that failed to parse on load so saves echo + * them back verbatim (no silent data loss). */ + setUnparsedDataTypeFiles: (files: RawProjectFile[]) => void // Tasks createTask: (dto: TaskDTO & { rowToInsert?: number }) => ProjectResponse @@ -324,6 +331,10 @@ export type ProjectSlice = { project: ProjectState /** Relative file paths queued for deletion on next full project save. */ pendingDeletions: string[] + /** Raw `datatypes/*.dt` files that failed to parse on load. Echoed + * back verbatim by the save flow until they parse — an unreadable + * file must never be silently dropped from disk. */ + unparsedDataTypeFiles: RawProjectFile[] /** * Session-scoped IEC alias memory: `memoryKey -> alias`, where `memoryKey` * is a channel's stable semantic identity (`vpp:moduleId:slot:channel`, diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index cc2b78116..ee77f42c2 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -279,10 +279,11 @@ const createSharedSlice: StateCreator = (s const datatype = state.project.data.dataTypes.find((d) => d.name === oldName) if (!datatype) return { ok: false, message: 'Data type not found' } - const updatedDatatype = { ...datatype, name: newName } - return renameElement(state, oldName, newName, () => { - state.projectActions.updateDatatype(oldName, updatedDatatype) + // Renames via the dedicated action so the old .dt path gets + // queued for deletion — a plain updateDatatype would strand + // the old file on disk. + state.projectActions.updateDatatypeName(oldName, newName) }) }, @@ -615,6 +616,9 @@ const createSharedSlice: StateCreator = (s meta: data.meta, data: data.projectData, }) + // Raw .dt files that failed to parse — stashed so saves echo + // them back verbatim; always set so a reopen clears stale ones. + getState().projectActions.setUnparsedDataTypeFiles(data.unparsedDataTypeFiles ?? []) // Add ladder and FBD flows for graphical POUs. // diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 5025c4129..d21432d2d 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -1,3 +1,4 @@ +import type { RawProjectFile } from '../../../../middleware/shared/ports/project-port' import type { DeviceConfiguration, DevicePin, @@ -134,6 +135,9 @@ export type OpenProjectResponseData = { devicePinMapping?: DevicePin[] | Record /** Warnings from parsing (e.g. dropped files that failed validation). */ warnings?: string[] + /** `datatypes/*.dt` files that failed to parse on load, preserved + * raw so the save flow echoes them back verbatim. */ + unparsedDataTypeFiles?: RawProjectFile[] /** * Edit permission flag forwarded from `ProjectResponse.data.canEdit`. * `false` puts the workspace in read-only mode; `true` / `undefined` diff --git a/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts b/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts index 06a3cae01..689bfe16c 100644 --- a/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts +++ b/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts @@ -183,6 +183,10 @@ describe('parseDataTypeFromText errors', () => { it('rejects invalid structure field names', () => { const badName = 'TYPE\n P : STRUCT\n 2bad : INT;\n END_STRUCT;\nEND_TYPE\n' expect(parseDataTypeFromText(badName).error).toMatch(/invalid structure field name: "2bad"/) + // Reserved words are rejected too — same isLegalIdentifier rule + // the structure form enforces at entry. + const keywordName = 'TYPE\n P : STRUCT\n IF : INT;\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(keywordName).error).toMatch(/invalid structure field name: "IF" — is a reserved word/) }) it('rejects unrecognized declarations with a hint', () => { @@ -193,6 +197,9 @@ describe('parseDataTypeFromText errors', () => { it('rejects an invalid type name', () => { expect(parseDataTypeFromText('TYPE\n 2bad : (Red);\nEND_TYPE\n').error).toMatch(/invalid type name/) + expect(parseDataTypeFromText('TYPE\n ARRAY : (Red);\nEND_TYPE\n').error).toMatch( + /invalid type name: "ARRAY" — is a reserved word/, + ) }) it('rejects a declared name that does not match the expected name', () => { diff --git a/src/frontend/utils/PLC/data-type-text-parser.ts b/src/frontend/utils/PLC/data-type-text-parser.ts index 157f14c02..3f9113db1 100644 --- a/src/frontend/utils/PLC/data-type-text-parser.ts +++ b/src/frontend/utils/PLC/data-type-text-parser.ts @@ -19,6 +19,7 @@ import { baseTypeSchema } from '../../../middleware/shared/ports/plc-schemas' import type { PLCDataType, PLCStructureVariable, PLCVariableType } from '../../../middleware/shared/ports/types' import { parseArrayType } from '../generate-iec-string-to-variables' +import { isLegalIdentifier } from '../keywords' export interface ParseDataTypeResult { dataType?: PLCDataType @@ -70,8 +71,9 @@ function parseStructure(name: string, body: string[]): ParseDataTypeResult { if (groups?.name === undefined || type === null) { return { error: `invalid structure field: "${line}". Possible cause: ${guessErrorReason(line)}` } } - if (!identifierRegex.test(groups.name)) { - return { error: `invalid structure field name: "${groups.name}"` } + const [fieldNameLegal, fieldNameReason] = isLegalIdentifier(groups.name) + if (!fieldNameLegal) { + return { error: `invalid structure field name: "${groups.name}" — ${fieldNameReason}` } } variable.push({ name: groups.name, @@ -144,8 +146,12 @@ export function parseDataTypeFromText(content: string, expectedName?: string): P : parseSingleLine(body[0]) if (result.dataType === undefined) return result - if (!identifierRegex.test(result.dataType.name)) { - return { error: `invalid type name: "${result.dataType.name}"` } + // Same rule the UI enforces at creation/rename (keywords, literals, + // identifier shape) — a hand-written file can't smuggle in a name + // the editor would refuse to work with. + const [nameLegal, nameReason] = isLegalIdentifier(result.dataType.name) + if (!nameLegal) { + return { error: `invalid type name: "${result.dataType.name}" — ${nameReason}` } } if (expectedName !== undefined) { diff --git a/src/frontend/utils/__tests__/feature-flags.test.ts b/src/frontend/utils/__tests__/feature-flags.test.ts new file mode 100644 index 000000000..d4ec25cda --- /dev/null +++ b/src/frontend/utils/__tests__/feature-flags.test.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +import { isDataTypeFilesEnabled } from '../feature-flags' + +describe('feature flags', () => { + it('ships with the .dt data type persistence switched off', () => { + // Flipped to true by the DOPE-385 release PR; until then every + // build must behave exactly like the legacy project.json format. + expect(isDataTypeFilesEnabled()).toBe(false) + }) +}) diff --git a/src/frontend/utils/feature-flags.ts b/src/frontend/utils/feature-flags.ts new file mode 100644 index 000000000..5c34ef77b --- /dev/null +++ b/src/frontend/utils/feature-flags.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Compile-time feature switches shared by both IDEs (mirrored file). + * + * `DATATYPES_DT_FILES` gates the per-type data type persistence + * (`datatypes/.dt`, DOPE-385): the write path and the read + * preference both key off it, so a build with the flag off behaves + * exactly like the legacy project.json format in every scenario. + * Flip the constant in the release PR once the whole DOPE-385 series + * has landed. + * + * Exposed as a function rather than a bare constant so call sites + * stay mockable in tests (both flag states need coverage). + */ +const DATATYPES_DT_FILES = false + +export const isDataTypeFilesEnabled = (): boolean => DATATYPES_DT_FILES diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index a6caf6c30..0359e449d 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -11,6 +11,7 @@ */ import { parseProjectFiles } from '../../../backend/shared/utils/parse-project-files' +import { isDataTypeFilesEnabled } from '../../../frontend/utils/feature-flags' import type { CreatePouParams, CreateProjectParams, @@ -202,6 +203,9 @@ export function createEditorProjectAdapter(): ProjectPort { raw.data.serverFiles, raw.data.remoteDeviceFiles, raw.data.libraryManifest, + // .dt files only feed the parser while the flag is on — + // off keeps legacy project.json as the source of truth. + isDataTypeFilesEnabled() ? raw.data.dataTypeFiles : [], ) return { success: true, data: parsed } }, @@ -221,6 +225,9 @@ export function createEditorProjectAdapter(): ProjectPort { raw.data.serverFiles, raw.data.remoteDeviceFiles, raw.data.libraryManifest, + // .dt files only feed the parser while the flag is on — + // off keeps legacy project.json as the source of truth. + isDataTypeFilesEnabled() ? raw.data.dataTypeFiles : [], ) return { success: true, data: parsed } }, diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 9ff762761..1925497ad 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -53,6 +53,9 @@ export interface ProjectResponse { devicePinMapping?: DevicePin[] | Record /** Warnings from parsing (e.g. dropped files that failed validation). */ warnings?: string[] + /** `datatypes/*.dt` files that failed to parse on load, preserved + * raw so the save flow echoes them back verbatim. */ + unparsedDataTypeFiles?: RawProjectFile[] /** * Raw file contents as returned by the backend (path → text), captured * before parsing. Used by the save flow to upload byte-identical content From d42ba16a9cb4914696f3b63ab651c2ee9c86a862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 22:48:45 -0300 Subject: [PATCH 40/79] test(datatypes): drop non-null assertions from datatype history tests Replace find(...)! lookups with a throwing helper per the coding guideline banning non-null assertions. DOPE-534 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LCcPrjCcCF29NQbG8xbwA --- .../store/__tests__/shared-slice.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index f6c9536c4..5d43bd02b 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1479,8 +1479,14 @@ describe('createSharedSlice', () => { store.getState().datatypeActions.create({ name: 'Colors', derivation: 'enumerated' }) }) + const getColorsDataType = () => { + const dataType = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors') + if (!dataType) throw new Error('Colors data type missing') + return dataType + } + it('undo restores the snapshot data type and moves the current entry to future', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().projectActions.updateDatatype('Colors', edited) @@ -1494,7 +1500,7 @@ describe('createSharedSlice', () => { }) it('redo reapplies the undone data type edit', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().projectActions.updateDatatype('Colors', edited) store.getState().snapshotActions.undo('Colors') @@ -1509,7 +1515,7 @@ describe('createSharedSlice', () => { }) it('undo marks the data type file saved when history returns to the saved depth', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().snapshotActions.markSaved('Colors') store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) @@ -1522,7 +1528,7 @@ describe('createSharedSlice', () => { }) it('undo marks the data type file unsaved when history diverges from the saved depth', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [edited] }) store.getState().snapshotActions.markSaved('Colors') @@ -1536,7 +1542,7 @@ describe('createSharedSlice', () => { }) it('redo marks the data type file unsaved when history diverges from the saved depth', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().snapshotActions.undo('Colors') store.getState().fileActions.updateFile({ name: 'Colors', saved: true }) From 6528fce79ba05e91ba1aeb4377790ab92f2798d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 22:53:35 -0300 Subject: [PATCH 41/79] fix(datatypes): guard duplicate .dt paths and unvalidated IPC file lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the .dt save/load switch: a name owned by an unreadable datatypes/.dt file is rejected at create/rename/ duplicate (case-insensitive — the file name is the identity), and the save iterators skip a raw echo whose path a parsed type already claims, so non-UI entry points can't emit two specs for one path. The editor adapter also array-guards dataTypeFiles from the unvalidated IPC payload before feeding the parser. DOPE-533 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019f6Kfu7zebQC3UdeN29tiq --- src/frontend/services/save-actions.ts | 3 ++ .../store/__tests__/shared-slice.test.ts | 28 +++++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 27 ++++++++++++++++++ .../adapters/editor/project-adapter.ts | 8 ++++-- 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 1e24bb536..ab04b7474 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -144,7 +144,10 @@ function* iterateProjectFiles(state: StoreState): Generator { } // Raw .dt files that failed to parse on load — echoed verbatim // so an unreadable file is never silently dropped from disk. + // A parsed type claiming the same path wins: guards non-UI entry + // points (e.g. XML import) from yielding duplicate paths. for (const f of state.unparsedDataTypeFiles) { + if (project.data.dataTypes.some((dt) => `datatypes/${dt.name}.dt` === f.relativePath)) continue yield { path: f.relativePath, content: f.content, category: 'data-type' } } } diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index a0c55d53d..b2fb410e1 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -469,6 +469,16 @@ describe('createSharedSlice', () => { expect(store.getState().project.data.dataTypes[0].derivation).toBe('structure') }) + it('rejects a name owned by an unreadable .dt file', () => { + store + .getState() + .projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/Ghost.dt', content: 'TYPE garbage' }]) + const result = store.getState().datatypeActions.create({ name: 'Ghost', derivation: 'structure' }) + expect(result.ok).toBe(false) + expect(result.message).toMatch(/could not be read/) + expect(store.getState().project.data.dataTypes).toHaveLength(0) + }) + it('creates an enumerated data type', () => { const result = store.getState().datatypeActions.create({ name: 'Colors', derivation: 'enumerated' }) expect(result.ok).toBe(true) @@ -559,6 +569,15 @@ describe('createSharedSlice', () => { expect(store.getState().pendingDeletions).toContain('datatypes/OldDT.dt') }) + it('rejects a name owned by an unreadable .dt file (case-insensitive)', () => { + store + .getState() + .projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/Ghost.dt', content: 'TYPE garbage' }]) + const result = store.getState().datatypeActions.rename('OldDT', 'ghost') + expect(result.ok).toBe(false) + expect(result.message).toMatch(/could not be read/) + }) + it('returns error when new name already exists', () => { store.getState().datatypeActions.create({ name: 'Existing', derivation: 'array' }) const result = store.getState().datatypeActions.rename('OldDT', 'Existing') @@ -612,6 +631,15 @@ describe('createSharedSlice', () => { expect(result.message).toBe('Data type not found') }) + it('rejects a duplicate name owned by an unreadable .dt file', () => { + store + .getState() + .projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/Ghost.dt', content: 'TYPE garbage' }]) + const result = store.getState().datatypeActions.duplicate('SourceDT', 'Ghost') + expect(result.ok).toBe(false) + expect(result.message).toMatch(/could not be read/) + }) + it('returns error when new name already exists', () => { store.getState().datatypeActions.create({ name: 'Existing', derivation: 'structure' }) const result = store.getState().datatypeActions.duplicate('SourceDT', 'Existing') diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index ee77f42c2..02b6c41d7 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -63,6 +63,24 @@ function validateElementName(name: string): { ok: true } | { ok: false; message: return legal ? { ok: true } : { ok: false, message: `'${name}' ${reason}` } } +/** + * A raw datatypes/.dt file that failed to parse still owns its + * name: letting a new data type take it would make the save emit two + * specs for one path (and the raw echo would win). Case-insensitive — + * the file name is the identity and common filesystems fold case. + */ +function collidesWithUnparsedDataTypeFile(state: SharedRootState, name: string): { ok: boolean; message?: string } { + const collides = state.unparsedDataTypeFiles.some( + (f) => f.relativePath.split('/').pop()?.replace(/\.dt$/i, '').toLowerCase() === name.toLowerCase(), + ) + return collides + ? { + ok: false, + message: `A data type file named "${name}.dt" exists on disk but could not be read — fix or remove it first`, + } + : { ok: true } +} + function renameElement( state: SharedRootState, oldName: string, @@ -240,6 +258,9 @@ const createSharedSlice: StateCreator = (s const existing = state.project.data.dataTypes.find((d) => d.name === name) if (existing) return { ok: false, message: 'Data type already exists' } + const fileCollision = collidesWithUnparsedDataTypeFile(state, name) + if (!fileCollision.ok) return fileCollision + const nameCheck = validateElementName(name) if (!nameCheck.ok) return nameCheck @@ -276,6 +297,9 @@ const createSharedSlice: StateCreator = (s const existing = state.project.data.dataTypes.find((d) => d.name === newName) if (existing) return { ok: false, message: 'Data type name already exists' } + const fileCollision = collidesWithUnparsedDataTypeFile(state, newName) + if (!fileCollision.ok) return fileCollision + const datatype = state.project.data.dataTypes.find((d) => d.name === oldName) if (!datatype) return { ok: false, message: 'Data type not found' } @@ -295,6 +319,9 @@ const createSharedSlice: StateCreator = (s const existing = state.project.data.dataTypes.find((d) => d.name === newName) if (existing) return { ok: false, message: 'Data type name already exists' } + const fileCollision = collidesWithUnparsedDataTypeFile(state, newName) + if (!fileCollision.ok) return fileCollision + const nameCheck = validateElementName(newName) if (!nameCheck.ok) return nameCheck diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index 0359e449d..6ea829762 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -205,7 +205,9 @@ export function createEditorProjectAdapter(): ProjectPort { raw.data.libraryManifest, // .dt files only feed the parser while the flag is on — // off keeps legacy project.json as the source of truth. - isDataTypeFilesEnabled() ? raw.data.dataTypeFiles : [], + // Array guard: the IPC payload is a cast, not validated — a + // version-skewed main process must not crash project open. + isDataTypeFilesEnabled() && Array.isArray(raw.data.dataTypeFiles) ? raw.data.dataTypeFiles : [], ) return { success: true, data: parsed } }, @@ -227,7 +229,9 @@ export function createEditorProjectAdapter(): ProjectPort { raw.data.libraryManifest, // .dt files only feed the parser while the flag is on — // off keeps legacy project.json as the source of truth. - isDataTypeFilesEnabled() ? raw.data.dataTypeFiles : [], + // Array guard: the IPC payload is a cast, not validated — a + // version-skewed main process must not crash project open. + isDataTypeFilesEnabled() && Array.isArray(raw.data.dataTypeFiles) ? raw.data.dataTypeFiles : [], ) return { success: true, data: parsed } }, From 04309015f0cccb5a3a8c20b450ced6f915f534db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 6 Aug 2026 10:42:13 -0300 Subject: [PATCH 42/79] fix(datatypes): compare data type names case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each data type name becomes a datatypes/.dt path and macOS and Windows fold filename case, so Foo and foo would resolve to one file and silently overwrite each other on save. Create, rename and duplicate now reject a name that differs from an existing one only by case, including a case-only rename of the type itself (the save writes the new file before deleting the old path — the same file where case folds). DOPE-533 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019f6Kfu7zebQC3UdeN29tiq --- .../store/__tests__/shared-slice.test.ts | 35 +++++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 19 +++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index b2fb410e1..c4602636b 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -479,6 +479,14 @@ describe('createSharedSlice', () => { expect(store.getState().project.data.dataTypes).toHaveLength(0) }) + it('rejects a name differing only by case (one file per name on case-folding disks)', () => { + store.getState().datatypeActions.create({ name: 'Motor', derivation: 'structure' }) + const result = store.getState().datatypeActions.create({ name: 'motor', derivation: 'structure' }) + expect(result.ok).toBe(false) + expect(result.message).toBe('Data type already exists') + expect(store.getState().project.data.dataTypes).toHaveLength(1) + }) + it('creates an enumerated data type', () => { const result = store.getState().datatypeActions.create({ name: 'Colors', derivation: 'enumerated' }) expect(result.ok).toBe(true) @@ -578,6 +586,27 @@ describe('createSharedSlice', () => { expect(result.message).toMatch(/could not be read/) }) + it('rejects a rename that collides with another type only by case', () => { + store.getState().datatypeActions.create({ name: 'Motor', derivation: 'array' }) + const result = store.getState().datatypeActions.rename('OldDT', 'motor') + expect(result.ok).toBe(false) + expect(result.message).toBe('Data type name already exists') + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toEqual(['OldDT', 'Motor']) + }) + + it('rejects a case-only rename of the type itself', () => { + // Writing olddt.dt then deleting OldDT.dt is the same file + // where the filesystem folds case — the type would vanish. + const result = store.getState().datatypeActions.rename('OldDT', 'olddt') + expect(result.ok).toBe(false) + expect(result.message).toBe('Data type name already exists') + }) + + it('allows a no-op rename to the identical name', () => { + const result = store.getState().datatypeActions.rename('OldDT', 'OldDT') + expect(result.ok).toBe(true) + }) + it('returns error when new name already exists', () => { store.getState().datatypeActions.create({ name: 'Existing', derivation: 'array' }) const result = store.getState().datatypeActions.rename('OldDT', 'Existing') @@ -631,6 +660,12 @@ describe('createSharedSlice', () => { expect(result.message).toBe('Data type not found') }) + it('rejects a duplicate name differing only by case', () => { + const result = store.getState().datatypeActions.duplicate('SourceDT', 'sourcedt') + expect(result.ok).toBe(false) + expect(result.message).toBe('Data type name already exists') + }) + it('rejects a duplicate name owned by an unreadable .dt file', () => { store .getState() diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 02b6c41d7..22afdcf7f 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -63,6 +63,14 @@ function validateElementName(name: string): { ok: true } | { ok: false; message: return legal ? { ok: true } : { ok: false, message: `'${name}' ${reason}` } } +/** + * Data type names are compared case-insensitively: each one becomes a + * `datatypes/.dt` path, and macOS/Windows fold filename case, so + * `Foo` and `foo` would silently overwrite each other on save. IEC + * identifiers are case-insensitive anyway. + */ +const nameMatches = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() + /** * A raw datatypes/.dt file that failed to parse still owns its * name: letting a new data type take it would make the save emit two @@ -255,7 +263,7 @@ const createSharedSlice: StateCreator = (s datatypeActions: { create: ({ name, derivation }) => { const state = getState() - const existing = state.project.data.dataTypes.find((d) => d.name === name) + const existing = state.project.data.dataTypes.find((d) => nameMatches(d.name, name)) if (existing) return { ok: false, message: 'Data type already exists' } const fileCollision = collidesWithUnparsedDataTypeFile(state, name) @@ -294,8 +302,11 @@ const createSharedSlice: StateCreator = (s rename: (oldName, newName) => { const state = getState() - const existing = state.project.data.dataTypes.find((d) => d.name === newName) - if (existing) return { ok: false, message: 'Data type name already exists' } + // Includes the type being renamed: a case-only change writes the + // new file and then deletes the old path — the same file where + // the filesystem folds case. + const collides = newName !== oldName && state.project.data.dataTypes.some((d) => nameMatches(d.name, newName)) + if (collides) return { ok: false, message: 'Data type name already exists' } const fileCollision = collidesWithUnparsedDataTypeFile(state, newName) if (!fileCollision.ok) return fileCollision @@ -316,7 +327,7 @@ const createSharedSlice: StateCreator = (s const source = state.project.data.dataTypes.find((d) => d.name === sourceName) if (!source) return { ok: false, message: 'Data type not found' } - const existing = state.project.data.dataTypes.find((d) => d.name === newName) + const existing = state.project.data.dataTypes.find((d) => nameMatches(d.name, newName)) if (existing) return { ok: false, message: 'Data type name already exists' } const fileCollision = collidesWithUnparsedDataTypeFile(state, newName) From 8d070f5d9c55b66e0ffe269854d09caf97c959cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 6 Aug 2026 10:47:24 -0300 Subject: [PATCH 43/79] =?UTF-8?q?fix(datatypes):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20capture=20test,=20name-source=20unification,=20rena?= =?UTF-8?q?me=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups from PR #649/#989 (Gustavo): - Add capture-side tests for usePouSnapshot: the datatype fallback (the core of the original bug) was only covered on the restore side, so deleting it left the suite green. - Unify capture and dirty-marking on editor.meta.name in the datatype molecules: data.name lags one render behind the store, so right after a rename the dirty call hit the orphaned old file key and silently marked nothing. - Rekey the undoRedo bucket in renameElement (new snapshotActions.renameHistory): history was orphaned under the old name after rename, turning undo into a silent no-op. Restored datatype snapshots pin name to the current history key so pre-rename snapshots can't desync tabs/files/editors. - Document the single-element dataTypes shape and the null body of datatype snapshots in PouHistorySnapshot. DOPE-534 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LCcPrjCcCF29NQbG8xbwA --- .../_molecules/data-types/array/index.tsx | 6 +- .../data-types/array/table/index.tsx | 2 +- .../data-types/enumerated/index.tsx | 4 +- .../data-types/enumerated/table/index.tsx | 2 +- .../hooks/__tests__/use-pou-snapshot.test.ts | 66 +++++++++++++++++++ .../store/__tests__/shared-slice.test.ts | 34 ++++++++++ src/frontend/store/slices/shared/slice.ts | 27 +++++++- src/frontend/store/slices/shared/types.ts | 5 +- 8 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 src/frontend/hooks/__tests__/use-pou-snapshot.test.ts diff --git a/src/frontend/components/_molecules/data-types/array/index.tsx b/src/frontend/components/_molecules/data-types/array/index.tsx index fb70e63da..0b236d98e 100644 --- a/src/frontend/components/_molecules/data-types/array/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/index.tsx @@ -112,7 +112,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const updatedData = { ...data } updatedData.initialValue = e.target.value updateDatatype(data.name, updatedData as PLCArrayDatatype) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const handleSelect = (definition: string, value: string) => { @@ -122,7 +122,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { ...data, baseType: { value, definition }, } as PLCArrayDatatype) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } // `updateDatatype` is a full replace — never pass a partial object, @@ -130,7 +130,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { // gets stripped and downstream selectors lose the entry. const writeDimensions = (newRows: PLCArrayDatatype['dimensions']) => { updateDatatype(data.name, { ...data, dimensions: newRows }) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const addNewRow = () => { diff --git a/src/frontend/components/_molecules/data-types/array/table/index.tsx b/src/frontend/components/_molecules/data-types/array/table/index.tsx index 8c4239190..8b84e1413 100644 --- a/src/frontend/components/_molecules/data-types/array/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/table/index.tsx @@ -49,7 +49,7 @@ const DimensionsTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'array') return updateDatatype(name, { ...current, dimensions: newDimensions }) - handleFileAndWorkspaceSavedState(name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const columnHelper = createColumnHelper<{ dimension: string }>() diff --git a/src/frontend/components/_molecules/data-types/enumerated/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/index.tsx index aec948fd9..6c57a6f06 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/index.tsx @@ -46,7 +46,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { ...data, initialValue: value, }) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } // `updateDatatype` is a full replace — spread `data` first so we @@ -54,7 +54,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { // downstream consumers. const writeValues = (newValues: PLCEnumeratedDatatype['values']) => { updateDatatype(data.name, { ...data, values: newValues }) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const addNewRow = () => { diff --git a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx index 0243d9f59..d6a3b4441 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx @@ -51,7 +51,7 @@ const EnumeratedTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'enumerated') return updateDatatype(name, { ...current, values: newValues }) - handleFileAndWorkspaceSavedState(name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const columnHelper = createColumnHelper<{ description: string }>() diff --git a/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts b/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts new file mode 100644 index 000000000..95c80713a --- /dev/null +++ b/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts @@ -0,0 +1,66 @@ +import { renderHook } from '@testing-library/react' + +import { useOpenPLCStore } from '../../store' +import { usePouSnapshot } from '../use-pou-snapshot' + +describe('usePouSnapshot', () => { + describe('captureAndPush', () => { + it('captures a data type snapshot keyed by the data type name', () => { + const created = useOpenPLCStore.getState().datatypeActions.create({ + name: 'CaptureColors', + derivation: 'enumerated', + }) + expect(created.ok).toBe(true) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureColors') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureColors'] + expect(bucket.past).toHaveLength(1) + expect(bucket.past[0].variables).toEqual([]) + expect(bucket.past[0].body).toBeNull() + expect(bucket.past[0].dataTypes).toEqual([ + expect.objectContaining({ name: 'CaptureColors', derivation: 'enumerated' }), + ]) + }) + + it('captures the current data type state, not the creation-time state', () => { + useOpenPLCStore.getState().datatypeActions.create({ name: 'CaptureDims', derivation: 'array' }) + const current = useOpenPLCStore.getState().project.data.dataTypes.find((d) => d.name === 'CaptureDims') + if (!current || current.derivation !== 'array') throw new Error('CaptureDims array data type missing') + useOpenPLCStore.getState().projectActions.updateDatatype('CaptureDims', { + ...current, + dimensions: [{ dimension: '0..7' }], + }) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureDims') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureDims'] + expect(bucket.past[0].dataTypes).toEqual([ + expect.objectContaining({ name: 'CaptureDims', dimensions: [{ dimension: '0..7' }] }), + ]) + }) + + it('captures a POU snapshot for POU names', () => { + useOpenPLCStore.getState().pouActions.create({ type: 'program', name: 'CaptureMain', language: 'st' }) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureMain') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureMain'] + expect(bucket.past).toHaveLength(1) + expect(bucket.past[0].dataTypes).toBeUndefined() + const pou = useOpenPLCStore.getState().project.data.pous.find((p) => p.name === 'CaptureMain') + if (!pou) throw new Error('CaptureMain POU missing') + expect(bucket.past[0].body).toBe(pou.body.value) + }) + + it('is a no-op for names matching neither a POU nor a data type', () => { + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureGhost') + + expect(useOpenPLCStore.getState().undoRedo['CaptureGhost']).toBeUndefined() + }) + }) +}) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 5d43bd02b..b66c5ae9c 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1079,6 +1079,23 @@ describe('createSharedSlice', () => { }) }) + // ----------------------------------------------------------------------- + // renameHistory + // ----------------------------------------------------------------------- + describe('renameHistory', () => { + it('moves the undo/redo bucket to the new key', () => { + store.getState().snapshotActions.pushToHistory('Old', snapshot1) + store.getState().snapshotActions.renameHistory('Old', 'New') + expect(store.getState().undoRedo['Old']).toBeUndefined() + expect(store.getState().undoRedo['New'].past).toEqual([snapshot1]) + }) + + it('does nothing when the old key has no history', () => { + store.getState().snapshotActions.renameHistory('Missing', 'New') + expect(store.getState().undoRedo['New']).toBeUndefined() + }) + }) + // ----------------------------------------------------------------------- // undo // ----------------------------------------------------------------------- @@ -1567,6 +1584,23 @@ describe('createSharedSlice', () => { expect(history.future[0].dataTypes).toEqual([edited]) }) + it('rename keeps the history and undo restores content under the new name', () => { + const initial = getColorsDataType() + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + + expect(store.getState().datatypeActions.rename('Colors', 'Palette').ok).toBe(true) + expect(store.getState().undoRedo['Colors']).toBeUndefined() + expect(store.getState().undoRedo['Palette'].past).toHaveLength(1) + + store.getState().snapshotActions.undo('Palette') + + // Content reverts, but the name stays pinned to the current key so + // tabs/files/editors (already rekeyed by the rename) don't desync. + const dataType = store.getState().project.data.dataTypes.find((d) => d.name === 'Palette') + expect(dataType).toEqual({ ...initial, name: 'Palette' }) + }) + it('redo leaves the data type untouched when the future snapshot has no dataTypes entry', () => { store.getState().projectActions.updateDatatype('Colors', edited) store.setState({ diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index ee0d6aa4b..85c357925 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -91,6 +91,10 @@ function renameElement( state.ladderFlowActions.renameLadderFlow(oldName, newName) state.fbdFlowActions.renameFBDFlow(oldName, newName) + // Follow the undo/redo stacks to the new key — otherwise the history is + // orphaned under the old name and undo becomes a silent no-op after rename. + state.snapshotActions.renameHistory(oldName, newName) + afterRename?.(oldName, newName) // A rename is an unsaved structural change — flag it dirty (the renamed file @@ -968,6 +972,17 @@ const createSharedSlice: StateCreator = (s ) }, + renameHistory: (oldName, newName) => { + setState( + produce((state: SharedRootState) => { + const history = state.undoRedo[oldName] + if (!history) return + delete state.undoRedo[oldName] + state.undoRedo[newName] = history + }), + ) + }, + markSaved: (pouName) => { setState( produce((state: SharedRootState) => { @@ -1052,7 +1067,11 @@ const createSharedSlice: StateCreator = (s } } else { const restoredDataType = snapshot.dataTypes?.[0] - if (restoredDataType) state.projectActions.applyDatatypeSnapshot(pouName, restoredDataType) + // Pin the name to the current key: snapshots taken before a rename + // carry the old name, and restoring it would desync tabs/files/editors. + if (restoredDataType) { + state.projectActions.applyDatatypeSnapshot(pouName, { ...restoredDataType, name: pouName }) + } } // Check if we've returned to the saved state @@ -1123,7 +1142,11 @@ const createSharedSlice: StateCreator = (s } } else { const restoredDataType = snapshot.dataTypes?.[0] - if (restoredDataType) state.projectActions.applyDatatypeSnapshot(pouName, restoredDataType) + // Pin the name to the current key: snapshots taken before a rename + // carry the old name, and restoring it would desync tabs/files/editors. + if (restoredDataType) { + state.projectActions.applyDatatypeSnapshot(pouName, { ...restoredDataType, name: pouName }) + } } // Check if we've returned to the saved state diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 64ac74eed..7fccab121 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -59,11 +59,13 @@ export type SharedResponse = { export type PouHistorySnapshot = { variables: PLCVariable[] + /** POU body; `null` for data type snapshots (`dataTypes` carries the state instead). */ body: unknown globalVariables?: PLCVariable[] ladderFlow?: unknown fbdFlow?: unknown - /** Set when the history key is a data type instead of a POU. */ + /** Set when the history key is a data type instead of a POU. Always a + * single element today — the array shape mirrors `HistorySnapshot.dataTypes`. */ dataTypes?: PLCDataType[] } @@ -119,6 +121,7 @@ export type EtherCATDeviceActions = { export type SnapshotActions = { pushToHistory: (pouName: string, snapshot: PouHistorySnapshot) => void + renameHistory: (oldName: string, newName: string) => void markSaved: (pouName: string) => void markAllSaved: (except?: readonly string[]) => void /** @returns `false` when the POU's graphical body is stale, so history was left untouched. */ From f5488dd81503900b5bb1ec1baab4bc322ca06176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 6 Aug 2026 15:32:13 -0300 Subject: [PATCH 44/79] feat(datatypes): propagate datatype rename into references behind impact modal (DOPE-536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming a referenced data type previously only renamed the type itself, leaving every referencing variable and type pointing at a dead name and breaking compilation. datatypeActions.rename is now async: it finds all references (POU variables, globals, struct fields, array base types), awaits a confirmation modal when any exist, and rewrites them before the rename. Cancel leaves the store untouched. Code-mode variable buffers are regenerated after propagation because sanitizePou persists editor.variable.code as the authoritative variables block on save, and every touched container file is flagged dirty so single-file save and the close-project check pick up the propagated content. The array display value is rebuilt too — variable serialization emits type.value verbatim into the saved declaration. Mirror of the openplc-web change (shared frontend surface). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Br23N5eg2d4ASekgSmTFzz --- .../_features/[workspace]/data-type/index.tsx | 17 +- .../_molecules/project-tree/index.tsx | 6 +- .../data-type-rename-impact-modal.tsx | 31 ++ .../_molecules/rename-impact-modal/index.tsx | 37 ++- .../components/_templates/app-layout.tsx | 2 + .../store/__tests__/project-slice.test.ts | 125 ++++++++ .../store/__tests__/shared-slice.test.ts | 289 ++++++++++++++++-- src/frontend/store/slices/project/slice.ts | 20 ++ src/frontend/store/slices/project/types.ts | 7 +- src/frontend/store/slices/shared/slice.ts | 74 ++++- src/frontend/store/slices/shared/types.ts | 23 +- .../__tests__/data-type-references.test.ts | 254 +++++++++++++++ src/frontend/utils/data-type-references.ts | 139 +++++++++ .../utils/data-type-references/types.ts | 13 + .../utils/variable-references/types.ts | 4 +- 15 files changed, 994 insertions(+), 47 deletions(-) create mode 100644 src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx create mode 100644 src/frontend/utils/__tests__/data-type-references.test.ts create mode 100644 src/frontend/utils/data-type-references.ts create mode 100644 src/frontend/utils/data-type-references/types.ts diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index a177ff0e6..665c32321 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -53,12 +53,17 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { if (dataTypeName !== value) { // `datatypeActions.rename` validates the new name and rekeys the // editor model, tab, and file entry, then flags the file dirty. - const result = rename(dataTypeName, value) - if (!result.ok) { - setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) - toast({ title: 'Rename failed', description: result.message, variant: 'fail' }) - } - setIsEditing(false) + // Async: a referenced type awaits the impact modal first. + void rename(dataTypeName, value).then((result) => { + if (!result.ok) { + setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) + // A declined impact modal is a user choice, not a failure. + if (!result.cancelled) { + toast({ title: 'Rename failed', description: result.message, variant: 'fail' }) + } + } + setIsEditing(false) + }) } } diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index e0d859fd0..a770d0057 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -594,8 +594,10 @@ const ProjectTreeLeaf = ({ } if (isDatatype) { - const res = renameDatatype(label, newLabel) - if (!res.ok) setNewLabel(label || '') + // Async: a referenced type awaits the impact modal before renaming. + void renameDatatype(label, newLabel).then((res) => { + if (!res.ok) setNewLabel(label || '') + }) return } diff --git a/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx b/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx new file mode 100644 index 000000000..bcfd5e02f --- /dev/null +++ b/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx @@ -0,0 +1,31 @@ +import { useOpenPLCStore } from '../../../store' +import { RenameImpactModal } from '.' + +/** + * Store-driven host for the data type rename flow: `datatypeActions.rename` + * parks the awaited confirmation in `pendingDatatypeRename`, this renders the + * impact modal for it, and confirm/cancel resolve the pending promise via + * `respondToPendingRename`. + */ +export const DataTypeRenameImpactModal = () => { + const pending = useOpenPLCStore((s) => s.pendingDatatypeRename) + const respondToPendingRename = useOpenPLCStore((s) => s.datatypeActions.respondToPendingRename) + + if (!pending) return null + + return ( + respondToPendingRename(true)} + onCancel={() => respondToPendingRename(false)} + /> + ) +} diff --git a/src/frontend/components/_molecules/rename-impact-modal/index.tsx b/src/frontend/components/_molecules/rename-impact-modal/index.tsx index a089821ef..1871285f7 100644 --- a/src/frontend/components/_molecules/rename-impact-modal/index.tsx +++ b/src/frontend/components/_molecules/rename-impact-modal/index.tsx @@ -6,7 +6,15 @@ type RenameImpactModalProps = { oldName?: string newName?: string changes?: Array<{ oldName: string; newName?: string; oldType?: string; newType?: string }> - impact: ReferenceImpactAnalysis + // The modal only renders the aggregate maps, so any location shape works. + impact: ReferenceImpactAnalysis + // Copy overrides — defaults keep the original variable-rename wording. + title?: string + affectedListLabel?: string + byKindLabel?: string + confirmLabel?: string + cancelLabel?: string + cancelDescription?: string onConfirm: () => void onCancel: () => void } @@ -17,6 +25,12 @@ export const RenameImpactModal = ({ newName, changes, impact, + title = 'Variable Changes: Impact Analysis', + affectedListLabel = 'Affected POUs:', + byKindLabel = 'By Editor Type:', + confirmLabel = 'Yes, rename references', + cancelLabel = 'No, keep references unchanged', + cancelDescription = 'References will remain with the old name and will no longer match the renamed variable, causing them to become unresolved references', onConfirm, onCancel, }: RenameImpactModalProps) => { @@ -32,9 +46,7 @@ export const RenameImpactModal = ({ onClose={onCancel} > - - Variable Changes: Impact Analysis - + {title}
@@ -91,7 +103,9 @@ export const RenameImpactModal = ({ {impact.byPou.size > 0 && (
-
Affected POUs:
+
+ {affectedListLabel} +
    {Array.from(impact.byPou.entries()).map(([pouName, count]) => (
  • @@ -104,7 +118,7 @@ export const RenameImpactModal = ({ {impact.byEditorType.size > 0 && (
    -
    By Editor Type:
    +
    {byKindLabel}
      {Array.from(impact.byEditorType.entries()).map(([editorType, count]) => (
    • @@ -120,12 +134,11 @@ export const RenameImpactModal = ({

      What would you like to do?

      • - Yes, rename references: All references will be updated to use the - new name + {confirmLabel}: All references will be updated to use the new + name
      • - No, keep references unchanged: References will remain with the - old name and will no longer match the renamed variable, causing them to become unresolved references + {cancelLabel}: {cancelDescription}
    @@ -136,10 +149,10 @@ export const RenameImpactModal = ({ onClick={onCancel} className='h-8 w-full rounded bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-1000 dark:bg-neutral-850 dark:text-neutral-100' > - No, keep references unchanged + {cancelLabel} diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index 1f696ed8f..b054ebb00 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -8,6 +8,7 @@ import { ResolutionWarning } from '../_atoms/resolution-warning-message' import Toaster from '../_features/[app]/toast/toaster' import { ProjectModal } from '../_features/[start]/new-project/project-modal' import { AIConsentModal } from '../_features/[workspace]/editor/monaco/ai-consent-modal' +import { DataTypeRenameImpactModal } from '../_molecules/rename-impact-modal/data-type-rename-impact-modal' import AboutModal from '../_organisms/about-modal' import { RuntimeCreateUserModal, RuntimeDiscoverDevicesModal, RuntimeLoginModal } from '../_organisms/modals' import { ConfirmDeleteProjectModal } from '../_organisms/modals/confirm-delete-project-modal' @@ -127,6 +128,7 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['confirm-delete-project']?.open === true && ( )} + {modals?.['confirm-plcopen-import']?.open === true && ( )} diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index a20a20d53..d8d75253b 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -1160,6 +1160,131 @@ describe('createProjectSlice', () => { }) }) + describe('propagateDatatypeRename', () => { + const directRef = (name: string, typeName: string): PLCVariable => ({ + name, + class: 'local', + type: { definition: 'user-data-type', value: typeName }, + location: '', + documentation: '', + }) + const arrayRef = (name: string, typeName: string): PLCVariable => ({ + name, + class: 'local', + type: { + definition: 'array', + value: `ARRAY [0..4] OF ${typeName}`, + data: { + baseType: { definition: 'user-data-type', value: typeName }, + dimensions: [{ dimension: '0..4' }], + }, + }, + location: '', + documentation: '', + }) + + beforeEach(() => { + store.getState().projectActions.createPou({ + type: 'program', + data: { + language: 'st', + name: 'Main', + variables: [directRef('motor', 'MotorDef'), arrayRef('motors', 'motordef'), makeVariable('plain')], + body: makeBody(), + documentation: '', + }, + }) + store.getState().projectActions.setGlobalVariables({ + variables: [{ ...directRef('gMotor', 'MotorDef'), class: 'global' }, makeVariable('gPlain', 'global')], + }) + store.getState().projectActions.createDatatype({ + data: { + name: 'MotorDef', + derivation: 'structure', + variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }], + }, + }) + store.getState().projectActions.createDatatype({ + data: { + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: { definition: 'user-data-type', value: 'MotorDef' } }, + { name: 'id', type: { definition: 'base-type', value: 'INT' } }, + ], + }, + }) + store.getState().projectActions.createDatatype({ + data: { + name: 'MotorBank', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'MotorDef' }, + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }, + }) + }) + + it('rewrites direct and array POU variable references (case-insensitive)', () => { + store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef') + + const variables = store.getState().project.data.pous[0].interface?.variables ?? [] + expect(variables[0].type).toEqual({ definition: 'user-data-type', value: 'DriveDef' }) + expect(variables[1].type).toEqual({ + definition: 'array', + value: 'ARRAY [0..4] OF DriveDef', + data: { + baseType: { definition: 'user-data-type', value: 'DriveDef' }, + dimensions: [{ dimension: '0..4' }], + }, + }) + expect(variables[2].type).toEqual({ definition: 'base-type', value: 'INT' }) + }) + + it('rewrites global variable references', () => { + store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef') + + const globals = store.getState().project.data.configurations.resource.globalVariables + expect(globals[0].type).toEqual({ definition: 'user-data-type', value: 'DriveDef' }) + expect(globals[1].type).toEqual({ definition: 'base-type', value: 'INT' }) + }) + + it('rewrites other data types and leaves the renamed type entry itself alone', () => { + store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef') + + const dataTypes = store.getState().project.data.dataTypes + // The type's own entry is updateDatatypeName's job. + expect(dataTypes[0].name).toBe('MotorDef') + expect(dataTypes[1]).toEqual({ + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: { definition: 'user-data-type', value: 'DriveDef' } }, + { name: 'id', type: { definition: 'base-type', value: 'INT' } }, + ], + }) + expect(dataTypes[2]).toEqual({ + name: 'MotorBank', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'DriveDef' }, + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }) + }) + + it('is a no-op when nothing references the type', () => { + const before = store.getState().project + store.getState().projectActions.propagateDatatypeRename('Ghost', 'Phantom') + const after = store.getState().project + + expect(after.data.pous).toEqual(before.data.pous) + expect(after.data.configurations.resource.globalVariables).toEqual( + before.data.configurations.resource.globalVariables, + ) + expect(after.data.dataTypes).toEqual(before.data.dataTypes) + }) + }) + describe('setUnparsedDataTypeFiles', () => { it('replaces the stashed raw .dt files', () => { const raw = [{ relativePath: 'datatypes/Broken.dt', content: 'TYPE garbage' }] diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 6e2b688ac..84367e05d 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -561,8 +561,8 @@ describe('createSharedSlice', () => { store.getState().datatypeActions.create({ name: 'OldDT', derivation: 'structure' }) }) - it('renames data type across all slices', () => { - const result = store.getState().datatypeActions.rename('OldDT', 'NewDT') + it('renames data type across all slices', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'NewDT') expect(result).toEqual({ ok: true }) const state = store.getState() @@ -572,63 +572,312 @@ describe('createSharedSlice', () => { expect(state.tabs[0].name).toBe('NewDT') }) - it('queues the old datatypes/.dt path for deletion', () => { - store.getState().datatypeActions.rename('OldDT', 'NewDT') + it('queues the old datatypes/.dt path for deletion', async () => { + await store.getState().datatypeActions.rename('OldDT', 'NewDT') expect(store.getState().pendingDeletions).toContain('datatypes/OldDT.dt') }) - it('rejects a name owned by an unreadable .dt file (case-insensitive)', () => { + it('rejects a name owned by an unreadable .dt file (case-insensitive)', async () => { store .getState() .projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/Ghost.dt', content: 'TYPE garbage' }]) - const result = store.getState().datatypeActions.rename('OldDT', 'ghost') + const result = await store.getState().datatypeActions.rename('OldDT', 'ghost') expect(result.ok).toBe(false) expect(result.message).toMatch(/could not be read/) }) - it('rejects a rename that collides with another type only by case', () => { + it('rejects a rename that collides with another type only by case', async () => { store.getState().datatypeActions.create({ name: 'Motor', derivation: 'array' }) - const result = store.getState().datatypeActions.rename('OldDT', 'motor') + const result = await store.getState().datatypeActions.rename('OldDT', 'motor') expect(result.ok).toBe(false) expect(result.message).toBe('Data type name already exists') expect(store.getState().project.data.dataTypes.map((d) => d.name)).toEqual(['OldDT', 'Motor']) }) - it('rejects a case-only rename of the type itself', () => { + it('rejects a case-only rename of the type itself', async () => { // Writing olddt.dt then deleting OldDT.dt is the same file // where the filesystem folds case — the type would vanish. - const result = store.getState().datatypeActions.rename('OldDT', 'olddt') + const result = await store.getState().datatypeActions.rename('OldDT', 'olddt') expect(result.ok).toBe(false) expect(result.message).toBe('Data type name already exists') }) - it('allows a no-op rename to the identical name', () => { - const result = store.getState().datatypeActions.rename('OldDT', 'OldDT') + it('allows a no-op rename to the identical name', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'OldDT') expect(result.ok).toBe(true) }) - it('returns error when new name already exists', () => { + it('returns error when new name already exists', async () => { store.getState().datatypeActions.create({ name: 'Existing', derivation: 'array' }) - const result = store.getState().datatypeActions.rename('OldDT', 'Existing') + const result = await store.getState().datatypeActions.rename('OldDT', 'Existing') expect(result.ok).toBe(false) expect(result.message).toBe('Data type name already exists') }) - it('returns error when data type not found', () => { - const result = store.getState().datatypeActions.rename('NonExistent', 'NewName') + it('returns error when data type not found', async () => { + const result = await store.getState().datatypeActions.rename('NonExistent', 'NewName') expect(result.ok).toBe(false) expect(result.message).toBe('Data type not found') }) - it('updates editor name when renaming the current editor', () => { + it('updates editor name when renaming the current editor', async () => { // OldDT is the current editor expect(store.getState().editor.meta.name).toBe('OldDT') - const result = store.getState().datatypeActions.rename('OldDT', 'RenamedDT') + const result = await store.getState().datatypeActions.rename('OldDT', 'RenamedDT') expect(result.ok).toBe(true) expect(store.getState().editor.meta.name).toBe('RenamedDT') }) }) + // ----------------------------------------------------------------------- + // rename with references (impact modal) + // ----------------------------------------------------------------------- + describe('rename with references (impact modal)', () => { + const directRef = (name: string, typeName: string): PLCVariable => ({ + name, + class: 'local', + type: { definition: 'user-data-type', value: typeName }, + location: '', + documentation: '', + }) + const arrayRef = (name: string, typeName: string): PLCVariable => ({ + name, + class: 'local', + type: { + definition: 'array', + value: `ARRAY [0..4] OF ${typeName}`, + data: { + baseType: { definition: 'user-data-type', value: typeName }, + dimensions: [{ dimension: '0..4' }], + }, + }, + location: '', + documentation: '', + }) + + beforeEach(() => { + store.getState().datatypeActions.create({ name: 'OldDT', derivation: 'structure' }) + store.getState().datatypeActions.create({ name: 'Chassis', derivation: 'structure' }) + store.getState().projectActions.updateDatatype('Chassis', { + name: 'Chassis', + derivation: 'structure', + variable: [{ name: 'front', type: { definition: 'user-data-type', value: 'OldDT' } }], + }) + store.getState().datatypeActions.create({ name: 'Bank', derivation: 'array' }) + store.getState().projectActions.updateDatatype('Bank', { + name: 'Bank', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'OldDT' }, + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }) + store.getState().pouActions.create({ type: 'program', name: 'Main', language: 'st' }) + store.getState().projectActions.setPouVariables({ + pouName: 'Main', + variables: [directRef('motor', 'OldDT'), arrayRef('motors', 'olddt')], + }) + store.getState().projectActions.setGlobalVariables({ + variables: [{ ...directRef('gMotor', 'OldDT'), class: 'global' }], + }) + store.getState().fileActions.addFile({ name: 'Resource', type: 'resource', filePath: 'Resource' }) + store.getState().fileActions.setAllToSaved() + store.getState().workspaceActions.setEditingState('saved') + }) + + // The variables view of a POU model — active editor or stored model, + // same preference order the propagation sync uses. + const getVariableView = (name: string) => { + const state = store.getState() + const model = state.editor.meta.name === name ? state.editor : state.editorActions.getEditorFromEditors(name) + if (!model || (model.type !== 'plc-textual' && model.type !== 'plc-graphical')) return undefined + return model.variable + } + const getCodeBuffer = (name: string) => { + const view = getVariableView(name) + return view?.display === 'code' ? view.code : undefined + } + + it('parks a pending rename and leaves the store untouched until answered', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + + const pending = store.getState().pendingDatatypeRename + expect(pending?.oldName).toBe('OldDT') + expect(pending?.newName).toBe('NewDT') + expect(pending?.impact.totalReferences).toBe(5) + expect(Array.from(pending?.impact.byPou.entries() ?? [])).toEqual([ + ['Main', 2], + ['Global Variables', 1], + ['Chassis', 1], + ['Bank', 1], + ]) + // Nothing renamed while the modal is open. + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toEqual(['OldDT', 'Chassis', 'Bank']) + + store.getState().datatypeActions.respondToPendingRename(true) + await promise + }) + + it('confirm propagates every reference shape, then renames', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + const result = await promise + + expect(result).toEqual({ ok: true }) + expect(store.getState().pendingDatatypeRename).toBeNull() + + const state = store.getState() + const variables = state.project.data.pous[0].interface?.variables ?? [] + expect(variables[0].type).toEqual({ definition: 'user-data-type', value: 'NewDT' }) + expect(variables[1].type).toEqual({ + definition: 'array', + value: 'ARRAY [0..4] OF NewDT', + data: { + baseType: { definition: 'user-data-type', value: 'NewDT' }, + dimensions: [{ dimension: '0..4' }], + }, + }) + expect(state.project.data.configurations.resource.globalVariables[0].type).toEqual({ + definition: 'user-data-type', + value: 'NewDT', + }) + const chassis = state.project.data.dataTypes.find((d) => d.name === 'Chassis') + expect(chassis?.derivation === 'structure' && chassis.variable[0].type.value).toBe('NewDT') + const bank = state.project.data.dataTypes.find((d) => d.name === 'Bank') + expect(bank?.derivation === 'array' && bank.baseType.value).toBe('NewDT') + + // The type itself was renamed and the old file queued for deletion. + expect(state.project.data.dataTypes.map((d) => d.name)).toEqual(['NewDT', 'Chassis', 'Bank']) + expect(state.pendingDeletions).toContain('datatypes/OldDT.dt') + }) + + it('confirm flags every affected container file dirty', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const files = store.getState().files + expect(files['Main'].saved).toBe(false) + expect(files['Resource'].saved).toBe(false) + expect(files['Chassis'].saved).toBe(false) + expect(files['Bank'].saved).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + + it('cancel leaves the store completely untouched', async () => { + const before = store.getState() + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(false) + const result = await promise + + expect(result).toEqual({ ok: false, cancelled: true, message: 'Rename cancelled' }) + const after = store.getState() + expect(after.pendingDatatypeRename).toBeNull() + // Same object references — no slice was written at all. + expect(after.project).toBe(before.project) + expect(after.files).toBe(before.files) + expect(after.tabs).toBe(before.tabs) + expect(after.pendingDeletions).toBe(before.pendingDeletions) + }) + + it('skips the modal when nothing references the type', async () => { + store.getState().datatypeActions.create({ name: 'Lonely', derivation: 'enumerated' }) + const result = await store.getState().datatypeActions.rename('Lonely', 'Hermit') + + expect(result).toEqual({ ok: true }) + expect(store.getState().pendingDatatypeRename).toBeNull() + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toContain('Hermit') + }) + + it('skips the reference scan on a no-op rename to the identical name', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'OldDT') + + expect(result.ok).toBe(true) + expect(store.getState().pendingDatatypeRename).toBeNull() + // References untouched — there was nothing to propagate. + const variables = store.getState().project.data.pous[0].interface?.variables ?? [] + expect(variables[0].type.value).toBe('OldDT') + }) + + it('rejects an invalid new name before opening the modal', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'bad name') + + expect(result.ok).toBe(false) + expect(store.getState().pendingDatatypeRename).toBeNull() + }) + + it('respondToPendingRename without a pending request is a no-op', () => { + const before = store.getState() + store.getState().datatypeActions.respondToPendingRename(true) + expect(store.getState()).toBe(before) + }) + + it('regenerates the code-mode variables buffer when the affected POU is the active editor', async () => { + // pouActions.create left Main as the active editor. + expect(store.getState().editor.meta.name).toBe('Main') + store.getState().editorActions.updateModelVariablesForName('Main', { + display: 'code', + code: ' VAR\n motor : OldDT;\n END_VAR', + }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const code = getCodeBuffer('Main') + expect(code).toContain('NewDT') + expect(code).not.toContain('OldDT') + }) + + it('regenerates the buffer of a stored (non-active) POU model', async () => { + // Make something else the active editor so Main only lives in editors[]. + store.getState().datatypeActions.create({ name: 'Scratch', derivation: 'structure' }) + expect(store.getState().editor.meta.name).toBe('Scratch') + store.getState().editorActions.updateModelVariablesForName('Main', { + display: 'code', + code: ' VAR\n motor : OldDT;\n END_VAR', + }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const code = getCodeBuffer('Main') + expect(code).toContain('NewDT') + expect(code).not.toContain('OldDT') + }) + + it('leaves table-mode variable views alone', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + expect(getVariableView('Main')?.display).toBe('table') + }) + + it('tolerates an affected POU without an editor model', async () => { + store.getState().projectActions.createPou({ + type: 'program', + data: { + language: 'st', + name: 'Headless', + variables: [directRef('m', 'OldDT')], + body: { language: 'st', value: '' }, + documentation: '', + }, + }) + store.getState().fileActions.addFile({ name: 'Headless', type: 'program', filePath: 'Headless' }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + const result = await promise + + expect(result).toEqual({ ok: true }) + const headless = store.getState().project.data.pous.find((p) => p.name === 'Headless') + expect(headless?.interface?.variables[0].type.value).toBe('NewDT') + }) + }) + // ----------------------------------------------------------------------- // duplicate // ----------------------------------------------------------------------- @@ -1652,12 +1901,12 @@ describe('createSharedSlice', () => { expect(history.future[0].dataTypes).toEqual([edited]) }) - it('rename keeps the history and undo restores content under the new name', () => { + it('rename keeps the history and undo restores content under the new name', async () => { const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().projectActions.updateDatatype('Colors', edited) - expect(store.getState().datatypeActions.rename('Colors', 'Palette').ok).toBe(true) + expect((await store.getState().datatypeActions.rename('Colors', 'Palette')).ok).toBe(true) expect(store.getState().undoRedo['Colors']).toBeUndefined() expect(store.getState().undoRedo['Palette'].past).toHaveLength(1) diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 1ed7b6605..6f35169ea 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -32,6 +32,7 @@ import { } from '../../../../middleware/shared/utils/iec-address/registry' import type { TargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' import { resolveTargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' +import { renameDataTypeInDataType, renameDataTypeInVariableType } from '../../../utils/data-type-references' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' import { isLegalIdentifier } from '../../../utils/keywords' @@ -1096,6 +1097,25 @@ const createProjectSlice: StateCreator = }), ) }, + propagateDatatypeRename: (oldName, newName) => { + setState( + produce((slice: ProjectSlice) => { + for (const pou of slice.project.data.pous) { + for (const variable of pou.interface?.variables ?? []) { + const nextType = renameDataTypeInVariableType(variable.type, oldName, newName) + if (nextType) variable.type = nextType + } + } + for (const variable of slice.project.data.configurations.resource.globalVariables) { + const nextType = renameDataTypeInVariableType(variable.type, oldName, newName) + if (nextType) variable.type = nextType + } + slice.project.data.dataTypes = slice.project.data.dataTypes.map( + (dataType) => renameDataTypeInDataType(dataType, oldName, newName) ?? dataType, + ) + }), + ) + }, createArrayDimension: ({ name, derivation: _derivation }) => { setState( produce((slice: ProjectSlice) => { diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 5a1c1e8c2..a529ea0db 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -218,8 +218,13 @@ export type ProjectActions = { deleteDatatype: (name: string) => void updateDatatype: (name: string, data?: PLCDataType) => void /** Rename + queue the old `datatypes/.dt` path for deletion - * (model: `updatePouName`). Reference propagation is DOPE-536. */ + * (model: `updatePouName`). Reference propagation is + * `propagateDatatypeRename`, driven by `datatypeActions.rename`. */ updateDatatypeName: (oldName: string, newName: string) => void + /** Rewrite every reference to data type `oldName` (POU variables, global + * variables, other data types' fields / array base types) to `newName`. + * Does not touch the type's own entry — `updateDatatypeName` owns that. */ + propagateDatatypeRename: (oldName: string, newName: string) => void createArrayDimension: (args: { name: string; derivation: 'array' | 'enumerated' | 'structure' }) => void rearrangeStructureVariables: (args: { associatedDataType?: string; rowId: number; newIndex: number }) => void applyDatatypeSnapshot: (name: string, data: PLCDataType) => void diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 6922712c9..a01c5963a 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -2,6 +2,8 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' import { isValidIecIdentifier } from '../../../../middleware/shared/utils/ethercat' +import { findAllReferencesToDataType } from '../../../utils/data-type-references' +import type { DataTypeReferenceImpactAnalysis } from '../../../utils/data-type-references/types' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../../../utils/graphical/sync-nodes-with-variables' @@ -89,6 +91,42 @@ function collidesWithUnparsedDataTypeFile(state: SharedRootState, name: string): : { ok: true } } +/** + * Post-propagation bookkeeping for a confirmed data type rename: + * + * 1. Flag every touched container's file dirty — single-file save and the + * close-project check read these flags, and the propagated content + * would otherwise be silently dropped on disk. + * 2. Regenerate code-mode variable buffers of affected POUs. `sanitizePou` + * persists `editor.variable.code` as the authoritative variables block, + * so a stale buffer would resurrect the old type name on save. + */ +function syncAfterDatatypePropagation(state: SharedRootState, impact: DataTypeReferenceImpactAnalysis): void { + const dirtyFiles = new Set() + const affectedPous = new Set() + for (const ref of impact.references) { + // Global variables persist through the Resource entry in the file slice. + dirtyFiles.add(ref.kind === 'global-variable' ? 'Resource' : ref.container) + if (ref.kind === 'pou-variable') affectedPous.add(ref.container) + } + for (const name of dirtyFiles) { + state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState(name) + } + + for (const pouName of affectedPous) { + const model = state.editor.meta.name === pouName ? state.editor : state.editors.find((e) => e.meta.name === pouName) + if (!model || (model.type !== 'plc-textual' && model.type !== 'plc-graphical')) continue + if (model.variable.display !== 'code') continue + const pou = state.project.data.pous.find((p) => p.name === pouName) + /* istanbul ignore next -- defensive: a pou-variable reference implies the POU exists */ + if (!pou) continue + state.editorActions.updateModelVariablesForName(pouName, { + display: 'code', + code: generateIecVariablesToString(pou.interface?.variables ?? []), + }) + } +} + function renameElement( state: SharedRootState, oldName: string, @@ -133,6 +171,7 @@ function renameElement( const createSharedSlice: StateCreator = (setState, getState) => ({ undoRedo: {}, + pendingDatatypeRename: null, pouActions: { create: ({ type, name, language }) => { @@ -304,7 +343,7 @@ const createSharedSlice: StateCreator = (s delete: (name) => deleteElement(getState(), name, (n) => getState().projectActions.deleteDatatype(n)), - rename: (oldName, newName) => { + rename: async (oldName, newName) => { const state = getState() // Includes the type being renamed: a case-only change writes the // new file and then deletes the old path — the same file where @@ -318,14 +357,43 @@ const createSharedSlice: StateCreator = (s const datatype = state.project.data.dataTypes.find((d) => d.name === oldName) if (!datatype) return { ok: false, message: 'Data type not found' } - return renameElement(state, oldName, newName, () => { + // renameElement validates too, but checked up front so the impact + // modal never opens for a rename that would fail afterwards. + const nameCheck = validateElementName(newName) + if (!nameCheck.ok) return nameCheck + + if (newName !== oldName) { + const impact = findAllReferencesToDataType( + oldName, + state.project.data.pous, + state.project.data.configurations.resource.globalVariables, + state.project.data.dataTypes, + ) + if (impact.totalReferences > 0) { + const confirmed = await new Promise((resolve) => { + setState({ pendingDatatypeRename: { oldName, newName, impact, resolve } }) + }) + if (!confirmed) return { ok: false, cancelled: true, message: 'Rename cancelled' } + getState().projectActions.propagateDatatypeRename(oldName, newName) + syncAfterDatatypePropagation(getState(), impact) + } + } + + return renameElement(getState(), oldName, newName, () => { // Renames via the dedicated action so the old .dt path gets // queued for deletion — a plain updateDatatype would strand // the old file on disk. - state.projectActions.updateDatatypeName(oldName, newName) + getState().projectActions.updateDatatypeName(oldName, newName) }) }, + respondToPendingRename: (confirmed) => { + const pending = getState().pendingDatatypeRename + if (!pending) return + setState({ pendingDatatypeRename: null }) + pending.resolve(confirmed) + }, + duplicate: (sourceName, newName) => { const state = getState() const source = state.project.data.dataTypes.find((d) => d.name === sourceName) diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index bf936b634..83b96a869 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -7,6 +7,7 @@ import type { PLCVariable, ProjectMeta, } from '../../../../middleware/shared/ports/types' +import type { DataTypeReferenceImpactAnalysis } from '../../../utils/data-type-references/types' import type { AISlice } from '../ai' import type { ConsoleSlice } from '../console' import type { DeviceSlice } from '../device' @@ -93,11 +94,30 @@ export type PouActions = { duplicate: (sourceName: string, newName: string) => SharedResponse } +export type DatatypeRenameResponse = SharedResponse & { + /** True when the user declined the reference-impact modal — a user choice, not an error. */ + cancelled?: boolean +} + +/** A rename waiting on the reference-impact modal. `resolve` releases the + * `datatypeActions.rename` await; the modal fires it via + * `datatypeActions.respondToPendingRename`. */ +export type PendingDatatypeRename = { + oldName: string + newName: string + impact: DataTypeReferenceImpactAnalysis + resolve: (confirmed: boolean) => void +} + export type DatatypeActions = { create: (args: { name: string; derivation: 'array' | 'enumerated' | 'structure' }) => SharedResponse deleteRequest: (name: string) => void delete: (name: string) => SharedResponse - rename: (oldName: string, newName: string) => SharedResponse + /** Async: a rename of a referenced type awaits the impact modal before + * propagating the new name into every reference. Cancel = no state change. */ + rename: (oldName: string, newName: string) => Promise + /** Confirm (`true`) or cancel (`false`) the pending rename's impact modal. */ + respondToPendingRename: (confirmed: boolean) => void duplicate: (sourceName: string, newName: string) => SharedResponse } @@ -178,6 +198,7 @@ export type SharedWorkspaceActions = { export type SharedSlice = { undoRedo: Record + pendingDatatypeRename: PendingDatatypeRename | null pouActions: PouActions datatypeActions: DatatypeActions serverActions: ServerActions diff --git a/src/frontend/utils/__tests__/data-type-references.test.ts b/src/frontend/utils/__tests__/data-type-references.test.ts new file mode 100644 index 000000000..9a8a58918 --- /dev/null +++ b/src/frontend/utils/__tests__/data-type-references.test.ts @@ -0,0 +1,254 @@ +import type { PLCDataType, PLCPou, PLCVariable, PLCVariableType } from '../../../middleware/shared/ports/types' +import { + findAllReferencesToDataType, + GLOBAL_VARIABLES_CONTAINER, + renameDataTypeInDataType, + renameDataTypeInVariableType, + variableTypeReferencesDataType, +} from '../data-type-references' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const directType = (typeName: string): PLCVariableType => ({ definition: 'user-data-type', value: typeName }) + +const arrayType = (typeName: string, dimensions: string[] = ['0..4']): PLCVariableType => ({ + definition: 'array', + value: `ARRAY [${dimensions.join(', ')}] OF ${typeName}`, + data: { + baseType: { definition: 'user-data-type', value: typeName }, + dimensions: dimensions.map((dimension) => ({ dimension })), + }, +}) + +const baseType = (value = 'INT'): PLCVariableType => ({ definition: 'base-type', value }) + +const makeVariable = (name: string, type: PLCVariableType): PLCVariable => ({ + name, + class: 'local', + type, + location: '', + documentation: '', +}) + +const makePou = (name: string, variables: PLCVariable[]): PLCPou => ({ + name, + pouType: 'program', + interface: { variables }, + body: { language: 'st', value: '' }, + documentation: '', +}) + +// --------------------------------------------------------------------------- +// variableTypeReferencesDataType +// --------------------------------------------------------------------------- + +describe('variableTypeReferencesDataType', () => { + it('matches a direct user-data-type reference case-insensitively', () => { + expect(variableTypeReferencesDataType(directType('MotorDef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(directType('motordef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(directType('Other'), 'MotorDef')).toBe(false) + }) + + it('matches an array base type reference', () => { + expect(variableTypeReferencesDataType(arrayType('MotorDef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(arrayType('Other'), 'MotorDef')).toBe(false) + }) + + it('ignores arrays without structured data', () => { + const lossy: PLCVariableType = { definition: 'array', value: 'ARRAY [0..4] OF MotorDef' } + expect(variableTypeReferencesDataType(lossy, 'MotorDef')).toBe(false) + }) + + it('ignores arrays of base types', () => { + const ints: PLCVariableType = { + definition: 'array', + value: 'ARRAY [0..4] OF INT', + data: { baseType: { definition: 'base-type', value: 'INT' }, dimensions: [{ dimension: '0..4' }] }, + } + expect(variableTypeReferencesDataType(ints, 'MotorDef')).toBe(false) + }) + + it('ignores base-type and derived references', () => { + expect(variableTypeReferencesDataType(baseType(), 'MotorDef')).toBe(false) + expect(variableTypeReferencesDataType({ definition: 'derived', value: 'MotorDef' }, 'MotorDef')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// findAllReferencesToDataType +// --------------------------------------------------------------------------- + +describe('findAllReferencesToDataType', () => { + const pous: PLCPou[] = [ + makePou('Main', [ + makeVariable('motor', directType('MotorDef')), + makeVariable('motors', arrayType('motordef')), + makeVariable('plain', baseType()), + ]), + makePou('Aux', [makeVariable('other', directType('Unrelated'))]), + { name: 'NoInterface', pouType: 'program', body: { language: 'st', value: '' }, documentation: '' }, + ] + + const globalVariables: PLCVariable[] = [ + { ...makeVariable('gMotor', directType('MotorDef')), class: 'global' }, + { ...makeVariable('gPlain', baseType()), class: 'global' }, + ] + + const dataTypes: PLCDataType[] = [ + { name: 'MotorDef', derivation: 'structure', variable: [{ name: 'speed', type: baseType() }] }, + { + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('MotorDef') }, + { name: 'rear', type: arrayType('MotorDef') }, + { name: 'id', type: baseType() }, + ], + }, + { + name: 'MotorBank', + derivation: 'array', + baseType: directType('MotorDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }, + { name: 'Mode', derivation: 'enumerated', values: [{ description: 'Auto' }] }, + ] + + it('collects references from POU variables, globals, and other data types', () => { + const impact = findAllReferencesToDataType('MotorDef', pous, globalVariables, dataTypes) + + expect(impact.totalReferences).toBe(6) + expect(impact.references).toEqual([ + { kind: 'pou-variable', container: 'Main', variableName: 'motor' }, + { kind: 'pou-variable', container: 'Main', variableName: 'motors' }, + { kind: 'global-variable', container: GLOBAL_VARIABLES_CONTAINER, variableName: 'gMotor' }, + { kind: 'data-type-field', container: 'Chassis', variableName: 'front' }, + { kind: 'data-type-field', container: 'Chassis', variableName: 'rear' }, + { kind: 'data-type-base-type', container: 'MotorBank' }, + ]) + }) + + it('aggregates counts by container and by reference kind', () => { + const impact = findAllReferencesToDataType('MotorDef', pous, globalVariables, dataTypes) + + expect(Array.from(impact.byPou.entries())).toEqual([ + ['Main', 2], + [GLOBAL_VARIABLES_CONTAINER, 1], + ['Chassis', 2], + ['MotorBank', 1], + ]) + expect(Array.from(impact.byEditorType.entries())).toEqual([ + ['POU variables', 2], + ['global variables', 1], + ['data types', 3], + ]) + }) + + it('returns an empty analysis when nothing references the type', () => { + const impact = findAllReferencesToDataType('Ghost', pous, globalVariables, dataTypes) + + expect(impact.totalReferences).toBe(0) + expect(impact.byPou.size).toBe(0) + expect(impact.byEditorType.size).toBe(0) + expect(impact.references).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// renameDataTypeInVariableType +// --------------------------------------------------------------------------- + +describe('renameDataTypeInVariableType', () => { + it('renames a direct reference and keeps the rest of the type', () => { + expect(renameDataTypeInVariableType(directType('motordef'), 'MotorDef', 'DriveDef')).toEqual({ + definition: 'user-data-type', + value: 'DriveDef', + }) + }) + + it('renames an array base type and rebuilds the display value', () => { + const next = renameDataTypeInVariableType(arrayType('MotorDef', ['0..4', '1..2']), 'MotorDef', 'DriveDef') + expect(next).toEqual({ + definition: 'array', + value: 'ARRAY [0..4, 1..2] OF DriveDef', + data: { + baseType: { definition: 'user-data-type', value: 'DriveDef' }, + dimensions: [{ dimension: '0..4' }, { dimension: '1..2' }], + }, + }) + }) + + it('returns null for types that do not reference the old name', () => { + expect(renameDataTypeInVariableType(directType('Other'), 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInVariableType(arrayType('Other'), 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInVariableType(baseType(), 'MotorDef', 'DriveDef')).toBeNull() + expect( + renameDataTypeInVariableType({ definition: 'array', value: 'ARRAY [0..4] OF MotorDef' }, 'MotorDef', 'DriveDef'), + ).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// renameDataTypeInDataType +// --------------------------------------------------------------------------- + +describe('renameDataTypeInDataType', () => { + it('renames only the structure fields that reference the type', () => { + const chassis: PLCDataType = { + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('MotorDef') }, + { name: 'id', type: baseType() }, + ], + } + expect(renameDataTypeInDataType(chassis, 'MotorDef', 'DriveDef')).toEqual({ + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('DriveDef') }, + { name: 'id', type: baseType() }, + ], + }) + }) + + it('renames an array data type base type', () => { + const bank: PLCDataType = { + name: 'MotorBank', + derivation: 'array', + baseType: directType('MotorDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + } + expect(renameDataTypeInDataType(bank, 'MotorDef', 'DriveDef')).toEqual({ + name: 'MotorBank', + derivation: 'array', + baseType: directType('DriveDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }) + }) + + it('returns null when nothing references the type', () => { + const unrelatedStruct: PLCDataType = { + name: 'Point', + derivation: 'structure', + variable: [{ name: 'x', type: baseType() }], + } + const unrelatedArray: PLCDataType = { + name: 'Ints', + derivation: 'array', + baseType: baseType(), + initialValue: '', + dimensions: [{ dimension: '0..1' }], + } + const mode: PLCDataType = { name: 'Mode', derivation: 'enumerated', values: [{ description: 'Auto' }] } + + expect(renameDataTypeInDataType(unrelatedStruct, 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInDataType(unrelatedArray, 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInDataType(mode, 'MotorDef', 'DriveDef')).toBeNull() + }) +}) diff --git a/src/frontend/utils/data-type-references.ts b/src/frontend/utils/data-type-references.ts new file mode 100644 index 000000000..5596f87bd --- /dev/null +++ b/src/frontend/utils/data-type-references.ts @@ -0,0 +1,139 @@ +import type { PLCDataType, PLCPou, PLCVariable, PLCVariableType } from '../../middleware/shared/ports/types' +import type { + DataTypeReferenceImpactAnalysis, + DataTypeReferenceKind, + DataTypeReferenceLocation, +} from './data-type-references/types' + +/** Container label used for references declared in the global variables table. */ +export const GLOBAL_VARIABLES_CONTAINER = 'Global Variables' + +// IEC identifiers are case-insensitive — same rule as the store's data type name checks. +const nameMatches = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() + +const KIND_GROUP: Record = { + 'pou-variable': 'POU variables', + 'global-variable': 'global variables', + 'data-type-field': 'data types', + 'data-type-base-type': 'data types', +} + +/** True when `type` references the data type `typeName` — directly or as an array base type. */ +export function variableTypeReferencesDataType(type: PLCVariableType, typeName: string): boolean { + if (type.definition === 'user-data-type') { + return nameMatches(type.value, typeName) + } + if (type.definition === 'array') { + const baseType = type.data?.baseType + return baseType !== undefined && baseType.definition === 'user-data-type' && nameMatches(baseType.value, typeName) + } + return false +} + +/** + * Find every place `typeName` is referenced as a type: POU variables, global + * variables, other structures' fields, and other array data types' base types. + * Mirrors `findAllReferencesToVariable` and returns the same analysis shape so + * the rename impact modal renders it unchanged. + */ +export function findAllReferencesToDataType( + typeName: string, + pous: PLCPou[], + globalVariables: PLCVariable[], + dataTypes: PLCDataType[], +): DataTypeReferenceImpactAnalysis { + const references: DataTypeReferenceLocation[] = [] + + pous.forEach((pou) => { + ;(pou.interface?.variables ?? []).forEach((variable) => { + if (variableTypeReferencesDataType(variable.type, typeName)) { + references.push({ kind: 'pou-variable', container: pou.name, variableName: variable.name }) + } + }) + }) + + globalVariables.forEach((variable) => { + if (variableTypeReferencesDataType(variable.type, typeName)) { + references.push({ kind: 'global-variable', container: GLOBAL_VARIABLES_CONTAINER, variableName: variable.name }) + } + }) + + dataTypes.forEach((dataType) => { + if (dataType.derivation === 'structure') { + dataType.variable.forEach((field) => { + if (variableTypeReferencesDataType(field.type, typeName)) { + references.push({ kind: 'data-type-field', container: dataType.name, variableName: field.name }) + } + }) + } else if (dataType.derivation === 'array') { + if (variableTypeReferencesDataType(dataType.baseType, typeName)) { + references.push({ kind: 'data-type-base-type', container: dataType.name }) + } + } + }) + + const byPou = new Map() + const byEditorType = new Map() + references.forEach((ref) => { + byPou.set(ref.container, (byPou.get(ref.container) ?? 0) + 1) + const group = KIND_GROUP[ref.kind] + byEditorType.set(group, (byEditorType.get(group) ?? 0) + 1) + }) + + return { + totalReferences: references.length, + byPou, + byEditorType, + references, + } +} + +/** + * Rewrite a reference to `oldName` inside a variable type, or return `null` + * when the type doesn't reference it. + */ +export function renameDataTypeInVariableType( + type: PLCVariableType, + oldName: string, + newName: string, +): PLCVariableType | null { + if (type.definition === 'user-data-type' && nameMatches(type.value, oldName)) { + return { ...type, value: newName } + } + if (type.definition === 'array' && type.data) { + const { baseType, dimensions } = type.data + if (baseType.definition === 'user-data-type' && nameMatches(baseType.value, oldName)) { + const dims = dimensions.map((d) => d.dimension).join(', ') + return { + ...type, + // `value` is what variable serialization emits — rebuild it or the + // saved declaration keeps the old base type name. + value: `ARRAY [${dims}] OF ${newName}`, + data: { ...type.data, baseType: { ...baseType, value: newName } }, + } + } + } + return null +} + +/** + * Rewrite references to `oldName` inside another data type (structure fields, + * array base type), or return `null` when nothing references it. + */ +export function renameDataTypeInDataType(dataType: PLCDataType, oldName: string, newName: string): PLCDataType | null { + if (dataType.derivation === 'structure') { + let changed = false + const fields = dataType.variable.map((field) => { + const nextType = renameDataTypeInVariableType(field.type, oldName, newName) + if (!nextType) return field + changed = true + return { ...field, type: nextType } + }) + return changed ? { ...dataType, variable: fields } : null + } + if (dataType.derivation === 'array') { + const nextBaseType = renameDataTypeInVariableType(dataType.baseType, oldName, newName) + return nextBaseType ? { ...dataType, baseType: nextBaseType } : null + } + return null +} diff --git a/src/frontend/utils/data-type-references/types.ts b/src/frontend/utils/data-type-references/types.ts new file mode 100644 index 000000000..e26b5d232 --- /dev/null +++ b/src/frontend/utils/data-type-references/types.ts @@ -0,0 +1,13 @@ +import type { ReferenceImpactAnalysis } from '../variable-references/types' + +export type DataTypeReferenceKind = 'pou-variable' | 'global-variable' | 'data-type-field' | 'data-type-base-type' + +export type DataTypeReferenceLocation = { + kind: DataTypeReferenceKind + /** POU name, referencing data type name, or the global-variables table label. */ + container: string + /** Declaring variable / structure field name; absent for an array data type's base type. */ + variableName?: string +} + +export type DataTypeReferenceImpactAnalysis = ReferenceImpactAnalysis diff --git a/src/frontend/utils/variable-references/types.ts b/src/frontend/utils/variable-references/types.ts index 6c066809c..6f96416e8 100644 --- a/src/frontend/utils/variable-references/types.ts +++ b/src/frontend/utils/variable-references/types.ts @@ -10,9 +10,9 @@ export type VariableReferenceLocation = { columnEnd?: number } -export type ReferenceImpactAnalysis = { +export type ReferenceImpactAnalysis = { totalReferences: number byPou: Map byEditorType: Map - references: VariableReferenceLocation[] + references: Location[] } From 732745c8371c3508dd6080f801392733663d1dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 6 Aug 2026 16:26:37 -0300 Subject: [PATCH 45/79] feat(data-types): add a form/code toggle to the data type editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every data type tab gains the variables-style table/Monaco switch, with the code side showing that type's `.dt` text (DOPE-535). Committing the buffer parses it through `parseDataTypeFromText`, so an invalid declaration keeps the user on the text instead of silently dropping the edit, and renaming in the text stays rejected — the file name is the type's identity. `StructureTableType` becomes the same discriminated union the variables table uses, which is why `updateModelStructure` grows `display`/`code` and gains a name-scoped sibling: every open data type is mounted at once, so a background editor writing through the active-editor action would land on the wrong model. `display` is optional there on purpose — a row selection must never flip the view. The reconcile/regenerate pair keeps the buffer and the store in lockstep when something outside the code view moves the type. A tree rename folds pending text edits in first and refuses on invalid text, rather than regenerating over work the user hasn't committed; undo, redo and a disk revert regenerate so Monaco shows the restored state. An unreadable `datatypes/.dt` has no `PLCDataType`, so it can't appear in the project tree and until now had no way of being found or fixed in-app. Project open now registers it and pre-opens a code-mode tab holding the raw bytes, without stealing focus from the auto-opened POU; committing valid text promotes it to a real type. The store can't raise a toast (layer rule), so the parse warning still goes to the console. Gated by `isDataTypeFilesEnabled()`, which ships false — DOPE-542 owns the flip. Refs DOPE-535 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GvGrhB1LyJz9MBwYhjoBDY --- .../_features/[workspace]/data-type/index.tsx | 234 +++++++++++++++++- .../_molecules/data-types/structure/index.tsx | 12 +- .../store/__tests__/editor-slice.test.ts | 81 +++++- .../store/__tests__/project-slice.test.ts | 112 +++++++++ .../store/__tests__/shared-slice.test.ts | 56 +++++ .../store/__tests__/shared-utils.test.ts | 20 +- .../store/__tests__/tabs-utils.test.ts | 2 +- src/frontend/store/slices/editor/slice.ts | 37 ++- src/frontend/store/slices/editor/types.ts | 26 +- src/frontend/store/slices/project/slice.ts | 65 +++++ src/frontend/store/slices/project/types.ts | 7 + src/frontend/store/slices/shared/slice.ts | 46 +++- src/frontend/store/slices/shared/utils.ts | 12 +- src/frontend/store/slices/tabs/utils.ts | 2 +- 14 files changed, 678 insertions(+), 34 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index a177ff0e6..fc46a36c0 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -1,12 +1,20 @@ -import { ComponentPropsWithoutRef, useEffect, useState } from 'react' +import { ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react' import type { PLCDataType } from '../../../../../middleware/shared/ports/types' +import { CodeIcon } from '../../../../assets/icons/interface/CodeIcon' +import { TableIcon } from '../../../../assets/icons/interface/TableIcon' +import { usePouSnapshot } from '../../../../hooks/use-pou-snapshot' import { useOpenPLCStore } from '../../../../store' import { extractSearchQuery } from '../../../../store/slices/search/utils' +import { cn } from '../../../../utils/cn' +import { isDataTypeFilesEnabled } from '../../../../utils/feature-flags' +import { serializeDataTypeToText } from '../../../../utils/PLC/data-type-serializer' +import { parseDataTypeFromText } from '../../../../utils/PLC/data-type-text-parser' import { InputWithRef } from '../../../_atoms/input' import { ArrayDataType } from '../../../_molecules/data-types/array' import { EnumeratorDataType } from '../../../_molecules/data-types/enumerated' import { StructureDataType } from '../../../_molecules/data-types/structure' +import { VariablesCodeEditor } from '../../../_organisms/variables-code-editor' import { toast } from '../../[app]/toast/use-toast' type DatatypeEditorProps = ComponentPropsWithoutRef<'div'> & { @@ -15,23 +23,180 @@ type DatatypeEditorProps = ComponentPropsWithoutRef<'div'> & { const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { const { + editor, + editors, project: { data: { dataTypes }, }, + unparsedDataTypeFiles, + workspace: { + systemConfigs: { shouldUseDarkMode }, + }, datatypeActions: { rename }, + editorActions: { updateModelStructureForName }, + projectActions: { createDatatype, removeUnparsedDataTypeFile, updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, searchQuery, } = useOpenPLCStore() + const { captureAndPush } = usePouSnapshot() + + // Every open data type is mounted at once (workspace-screen keeps the + // inactive ones hidden), so the view state has to come from this + // type's own model — never from the active `editor`. + const model = editor.meta.name === dataTypeName ? editor : editors.find((e) => e.meta.name === dataTypeName) + const modelStructure = model?.type === 'plc-datatype' ? model.structure : undefined + const codeViewEnabled = isDataTypeFilesEnabled() + const display = codeViewEnabled && modelStructure?.display === 'code' ? 'code' : 'table' + const modelCode = modelStructure?.display === 'code' ? modelStructure.code : undefined + + // A `.dt` file that failed to parse has no entry in `dataTypes`; the + // raw text is all there is until the user fixes it. + const rawFile = unparsedDataTypeFiles.find( + (file) => file.relativePath.split('/').pop()?.replace(/\.dt$/i, '') === dataTypeName, + ) + const [editorContent, setEditorContent] = useState() const [isEditing, setIsEditing] = useState(false) + const [editorCode, setEditorCode] = useState(() => { + if (typeof modelCode === 'string') return modelCode + const dataType = dataTypes.find((candidate) => candidate.name === dataTypeName) + return dataType ? serializeDataTypeToText(dataType) : (rawFile?.content ?? '') + }) + const [parseError, setParseError] = useState(null) + + const containerRef = useRef(null) + const latestCodeRef = useRef(editorCode) + const latestDisplayRef = useRef(display) + const lastParsedCodeRef = useRef(editorCode) + const lastMirroredCodeRef = useRef(editorCode) + const isParsingRef = useRef(false) + const commitCodeRef = useRef<() => boolean>(() => false) useEffect(() => { - const dataTypeIndex = dataTypes.findIndex((dataType) => dataType.name === dataTypeName) - if (dataTypeIndex !== -1) { - const dataType = dataTypes[dataTypeIndex] - setEditorContent(dataType) - } + const dataType = dataTypes.find((candidate) => candidate.name === dataTypeName) + if (dataType) setEditorContent(dataType) }, [dataTypes, dataTypeName]) + // Keep the buffer serialized from the form while in table mode, so the + // toggle already holds the right text the moment it flips. + useEffect(() => { + if (display === 'code') return + const text = editorContent ? serializeDataTypeToText(editorContent) : (rawFile?.content ?? '') + setEditorCode(text) + // In table mode the form is the committed state, so this is also + // the watermark the next outside-click compares against. + lastParsedCodeRef.current = text + }, [editorContent, display, rawFile?.content]) + + // Adopt buffers written by the store (a tree rename or an undo + // regenerates them), but never the echo of our own mirror below — + // that would race the keystroke that produced it. + useEffect(() => { + if (display !== 'code' || typeof modelCode !== 'string') return + if (modelCode === lastMirroredCodeRef.current) return + setEditorCode(modelCode) + }, [display, modelCode]) + + useEffect(() => { + if (display !== 'code') return + lastMirroredCodeRef.current = editorCode + updateModelStructureForName(dataTypeName, { display: 'code', code: editorCode }) + }, [editorCode, display, dataTypeName, updateModelStructureForName]) + + useEffect(() => { + latestCodeRef.current = editorCode + latestDisplayRef.current = display + }, [editorCode, display]) + + useEffect(() => { + return () => { + if (latestDisplayRef.current === 'code') { + updateModelStructureForName(dataTypeName, { display: 'code', code: latestCodeRef.current }) + } + } + }, [dataTypeName, updateModelStructureForName]) + + // A type that doesn't exist yet is a broken file on disk: show why it + // is broken while the user edits, instead of only on commit. + useEffect(() => { + if (display !== 'code' || editorContent) return + setParseError(parseDataTypeFromText(editorCode, dataTypeName).error ?? null) + }, [display, editorContent, editorCode, dataTypeName]) + + const commitCode = (): boolean => { + const { dataType, error } = parseDataTypeFromText(editorCode, dataTypeName) + if (!dataType) { + const message = error ?? 'Unexpected syntax error.' + setParseError(message) + toast({ title: 'Syntax error', description: message, variant: 'fail' }) + return false + } + + captureAndPush(dataTypeName) + + if (editorContent) { + updateDatatype(dataTypeName, dataType) + } else { + const result = createDatatype({ data: dataType }) + if (!result.ok) { + const message = result.message ?? 'Could not create the data type.' + setParseError(message) + toast({ title: 'Syntax error', description: message, variant: 'fail' }) + return false + } + if (rawFile) removeUnparsedDataTypeFile(rawFile.relativePath) + } + + handleFileAndWorkspaceSavedState(dataTypeName) + setParseError(null) + return true + } + + useEffect(() => { + commitCodeRef.current = commitCode + }) + + useEffect(() => { + if (display !== 'code') return + + const tryCommit = () => { + if (isParsingRef.current) return + if (editorCode === lastParsedCodeRef.current) return + isParsingRef.current = true + if (commitCodeRef.current()) lastParsedCodeRef.current = editorCode + isParsingRef.current = false + } + + const onDocMouseDown = (e: MouseEvent) => { + if (!containerRef.current) return + if (containerRef.current.contains(e.target as Node)) return + tryCommit() + } + + // Covers keyboard navigation, Tab and shortcuts — anything that + // moves focus away without a mousedown. + const onFocusOut = (e: FocusEvent) => { + if (!containerRef.current) return + const newTarget = e.relatedTarget as Node | null + if (newTarget && containerRef.current.contains(newTarget)) return + tryCommit() + } + + const container = containerRef.current + document.addEventListener('mousedown', onDocMouseDown, true) + container?.addEventListener('focusout', onFocusOut) + return () => { + document.removeEventListener('mousedown', onDocMouseDown, true) + container?.removeEventListener('focusout', onFocusOut) + } + }, [display, editorCode]) + + const handleVisualizationTypeChange = (value: 'code' | 'table') => { + if (display === value) return + if (display === 'code' && !commitCode()) return + updateModelStructureForName(dataTypeName, { display: value, code: value === 'code' ? editorCode : undefined }) + } + const handleStartEditing = () => { setIsEditing(true) } @@ -64,6 +229,7 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { return (
    { > {isEditing ? ( { aria-label='Data type name' className='h-full w-full bg-transparent p-2 text-start font-caption text-xs text-neutral-850 outline-none dark:text-neutral-100' onClick={handleStartEditing} - dangerouslySetInnerHTML={{ __html: extractSearchQuery(editorContent?.name || '', searchQuery) }} + dangerouslySetInnerHTML={{ + __html: extractSearchQuery(editorContent?.name ?? dataTypeName, searchQuery), + }} /> )}
+ {codeViewEnabled && ( +
+ handleVisualizationTypeChange('table')} + size='md' + currentVisible={display === 'table'} + className={cn( + display === 'table' ? 'fill-brand' : 'fill-neutral-100 dark:fill-neutral-900', + 'rounded-l-md transition-colors ease-in-out hover:cursor-pointer', + )} + /> + handleVisualizationTypeChange('code')} + size='md' + currentVisible={display === 'code'} + className={cn( + display === 'code' ? 'fill-brand' : 'fill-neutral-100 dark:fill-neutral-900', + 'rounded-r-md transition-colors ease-in-out hover:cursor-pointer', + )} + /> +
+ )}
-
- {editorContent?.derivation === 'array' && } - {editorContent?.derivation === 'enumerated' && } - {editorContent?.derivation === 'structure' && } +
+ {display === 'table' ? ( + <> + {editorContent?.derivation === 'array' && } + {editorContent?.derivation === 'enumerated' && } + {editorContent?.derivation === 'structure' && } + + ) : ( + <> +
+ +
+ {parseError &&

Error: {parseError}

} + + )}
) diff --git a/src/frontend/components/_molecules/data-types/structure/index.tsx b/src/frontend/components/_molecules/data-types/structure/index.tsx index fd20b81c0..343f04b3e 100644 --- a/src/frontend/components/_molecules/data-types/structure/index.tsx +++ b/src/frontend/components/_molecules/data-types/structure/index.tsx @@ -28,7 +28,8 @@ const StructureDataType = () => { const [tableData, setTableData] = useState([]) - const [editorStructure, setEditorStructure] = useState({ + const [editorStructure, setEditorStructure] = useState>({ + display: 'table', selectedRow: ROWS_NOT_SELECTED.toString(), description: '', }) @@ -47,9 +48,14 @@ const StructureDataType = () => { useEffect(() => { const foundDataType = dataTypes.find((dataType) => dataType?.derivation === 'structure') - if (editor.type === 'plc-datatype' && foundDataType && 'variable' in foundDataType) { + if ( + editor.type === 'plc-datatype' && + editor.structure.display === 'table' && + foundDataType && + 'variable' in foundDataType + ) { const { description, selectedRow } = editor.structure - setEditorStructure({ description: description, selectedRow: selectedRow }) + setEditorStructure({ display: 'table', description: description, selectedRow: selectedRow }) } }, [editor]) diff --git a/src/frontend/store/__tests__/editor-slice.test.ts b/src/frontend/store/__tests__/editor-slice.test.ts index 4cf2c9e5f..8facf03dc 100644 --- a/src/frontend/store/__tests__/editor-slice.test.ts +++ b/src/frontend/store/__tests__/editor-slice.test.ts @@ -58,7 +58,7 @@ function makeDatatype(name: string): EditorModel { return { type: 'plc-datatype', meta: { name, derivation: 'structure' }, - structure: { selectedRow: '-1', description: '' }, + structure: { display: 'table', selectedRow: '-1', description: '' }, } } @@ -217,7 +217,11 @@ describe('editor slice', () => { a.setEditor(makeTextual('M')) a.updateModelVariablesForName('DT', { display: 'table', selectedRow: 1 }) const dtEditor = store.getState().editors.find((e) => e.meta.name === 'DT')! - expect(editorAs(dtEditor).structure.selectedRow).toBe('-1') + expect(editorAs(dtEditor).structure).toEqual({ + display: 'table', + selectedRow: '-1', + description: '', + }) }) }) @@ -281,12 +285,14 @@ describe('editor slice', () => { a.updateModelStructure({ selectedRow: 3, description: 'desc' }) expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'table', selectedRow: '3', description: 'desc', }) // undefined selectedRow → keeps; empty description → keeps (falsy) a.updateModelStructure({ description: '' }) expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'table', selectedRow: '3', description: 'desc', }) @@ -296,6 +302,77 @@ describe('editor slice', () => { store.getState().editorActions.updateModelStructure({ selectedRow: 1, description: 'x' }) expect(store.getState().editor.type).toBe('available') }) + + it('switches to code mode keeping the buffer, and back to table with defaults', () => { + const { editorActions: a } = store.getState() + const dt = makeDatatype('S') + a.addModel(dt) + a.setEditor(dt) + + a.updateModelStructure({ selectedRow: 2, description: 'desc' }) + a.updateModelStructure({ display: 'code', code: 'TYPE\nS : (A);\nEND_TYPE\n' }) + expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'code', + code: 'TYPE\nS : (A);\nEND_TYPE\n', + }) + + // no `code` on a code→code update keeps the existing buffer + a.updateModelStructure({ display: 'code' }) + expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'code', + code: 'TYPE\nS : (A);\nEND_TYPE\n', + }) + + // the table arm's fields don't survive a round trip through code mode + a.updateModelStructure({ display: 'table' }) + expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'table', + selectedRow: '-1', + description: '', + }) + }) + }) + + describe('updateModelStructureForName', () => { + it('updates a non-active model', () => { + const { editorActions: a } = store.getState() + const active = makeDatatype('Active') + const background = makeDatatype('Background') + a.addModel(active) + a.addModel(background) + a.setEditor(active) + + a.updateModelStructureForName('Background', { display: 'code', code: 'raw' }) + + const stored = store.getState().editors.find((e) => e.meta.name === 'Background') + expect(stored && editorAs(stored).structure).toEqual({ display: 'code', code: 'raw' }) + expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'table', + selectedRow: '-1', + description: '', + }) + }) + + it('updates the active model when the name matches', () => { + const { editorActions: a } = store.getState() + const dt = makeDatatype('S') + a.addModel(dt) + a.setEditor(dt) + + a.updateModelStructureForName('S', { display: 'code', code: 'raw' }) + expect(editorAs(store.getState().editor).structure).toEqual({ display: 'code', code: 'raw' }) + }) + + it('no-op for an unknown name or a non-datatype model', () => { + const { editorActions: a } = store.getState() + const textual = makeTextual('P') + a.addModel(textual) + a.setEditor(textual) + + a.updateModelStructureForName('missing', { display: 'code', code: 'raw' }) + a.updateModelStructureForName('P', { display: 'code', code: 'raw' }) + expect(store.getState().editor.type).toBe('plc-textual') + }) }) describe('updateModelLadder', () => { diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index a20a20d53..a034bd51a 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -21,6 +21,7 @@ import type { S7CommDataBlock, } from '../../../middleware/shared/ports/types' import { generateIecVariablesToString } from '../../utils/generate-iec-variables-to-string' +import { serializeDataTypeToText } from '../../utils/PLC/data-type-serializer' import { createConsoleSlice } from '../slices/console' import { createDeviceSlice } from '../slices/device' import { createEditorSlice } from '../slices/editor' @@ -47,6 +48,19 @@ function makeStore() { // Helpers // --------------------------------------------------------------------------- +function openDatatypeInCodeMode(store: ReturnType, name: string, code: string) { + store.getState().editorActions.addModel({ + type: 'plc-datatype', + meta: { name, derivation: 'enumerated' }, + structure: { display: 'code', code }, + }) +} + +function codeOf(store: ReturnType, name: string): string | undefined { + const model = store.getState().editors.find((e) => e.meta.name === name) + return model?.type === 'plc-datatype' && model.structure.display === 'code' ? model.structure.code : undefined +} + function makeVariable(name: string, cls: PLCVariable['class'] = 'local'): PLCVariable { return { name, @@ -1308,6 +1322,104 @@ describe('createProjectSlice', () => { store.getState().projectActions.applyDatatypeSnapshot('NonExistent', replacement) expect(store.getState().project.data.dataTypes[0].name).toBe('A') }) + + it('regenerates the code buffer of a type shown in code mode', () => { + const dt: PLCDataType = { name: 'A', derivation: 'enumerated', values: [{ description: 'RED' }] } + store.getState().projectActions.createDatatype({ data: dt }) + openDatatypeInCodeMode(store, 'A', 'stale text') + + store.getState().projectActions.applyDatatypeSnapshot('A', { + name: 'A', + derivation: 'enumerated', + values: [{ description: 'BLUE' }], + }) + + expect(codeOf(store, 'A')).toContain('BLUE') + }) + }) + + // ------------------------------------------------------------------------- + // Data-type code view + // ------------------------------------------------------------------------- + describe('reconcileDatatypeText', () => { + const enumType: PLCDataType = { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }] } + + it('is a no-op when the type is not shown in code mode', () => { + store.getState().projectActions.createDatatype({ data: enumType }) + expect(store.getState().projectActions.reconcileDatatypeText('Colors').ok).toBe(true) + expect(store.getState().project.data.dataTypes[0]).toEqual(enumType) + }) + + it('is a no-op when the buffer still matches the serialized type', () => { + store.getState().projectActions.createDatatype({ data: enumType }) + openDatatypeInCodeMode(store, 'Colors', serializeDataTypeToText(enumType)) + + expect(store.getState().projectActions.reconcileDatatypeText('Colors').ok).toBe(true) + expect(store.getState().project.data.dataTypes[0]).toEqual(enumType) + }) + + it('is a no-op when the type no longer exists', () => { + openDatatypeInCodeMode(store, 'Ghost', 'TYPE\nGhost : (RED);\nEND_TYPE\n') + expect(store.getState().projectActions.reconcileDatatypeText('Ghost').ok).toBe(true) + expect(store.getState().project.data.dataTypes).toHaveLength(0) + }) + + it('folds a diverged buffer back into the type', () => { + store.getState().projectActions.createDatatype({ data: enumType }) + openDatatypeInCodeMode(store, 'Colors', 'TYPE\nColors : (RED, GREEN);\nEND_TYPE\n') + + expect(store.getState().projectActions.reconcileDatatypeText('Colors').ok).toBe(true) + const updated = store.getState().project.data.dataTypes[0] + expect(updated.derivation === 'enumerated' && updated.values).toEqual([ + { description: 'RED' }, + { description: 'GREEN' }, + ]) + }) + + it('refuses when the buffer does not parse', () => { + store.getState().projectActions.createDatatype({ data: enumType }) + openDatatypeInCodeMode(store, 'Colors', 'TYPE\nnot a declaration\nEND_TYPE\n') + + const response = store.getState().projectActions.reconcileDatatypeText('Colors') + expect(response.ok).toBe(false) + expect(response.title).toBe('Data type text is invalid') + expect(store.getState().project.data.dataTypes[0]).toEqual(enumType) + }) + }) + + describe('regenerateDatatypeText', () => { + it('re-serializes the type into its buffer', () => { + const dt: PLCDataType = { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }] } + store.getState().projectActions.createDatatype({ data: dt }) + openDatatypeInCodeMode(store, 'Colors', 'stale text') + + store.getState().projectActions.regenerateDatatypeText('Colors') + expect(codeOf(store, 'Colors')).toBe(serializeDataTypeToText(dt)) + }) + + it('does nothing when the type is not shown in code mode', () => { + const dt: PLCDataType = { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }] } + store.getState().projectActions.createDatatype({ data: dt }) + store.getState().projectActions.regenerateDatatypeText('Colors') + expect(store.getState().editors).toHaveLength(0) + }) + + it('does nothing when the type no longer exists', () => { + openDatatypeInCodeMode(store, 'Ghost', 'raw') + store.getState().projectActions.regenerateDatatypeText('Ghost') + expect(codeOf(store, 'Ghost')).toBe('raw') + }) + }) + + describe('removeUnparsedDataTypeFile', () => { + it('drops only the matching path', () => { + store.getState().projectActions.setUnparsedDataTypeFiles([ + { relativePath: 'datatypes/A.dt', content: 'a' }, + { relativePath: 'datatypes/B.dt', content: 'b' }, + ]) + store.getState().projectActions.removeUnparsedDataTypeFile('datatypes/A.dt') + expect(store.getState().unparsedDataTypeFiles).toEqual([{ relativePath: 'datatypes/B.dt', content: 'b' }]) + }) }) // ========================================================================= diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 6e2b688ac..00271ba36 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -577,6 +577,35 @@ describe('createSharedSlice', () => { expect(store.getState().pendingDeletions).toContain('datatypes/OldDT.dt') }) + it('folds pending code-view edits in and rewrites the buffer under the new name', () => { + store.getState().editorActions.updateModelStructureForName('OldDT', { + display: 'code', + code: 'TYPE\nOldDT : STRUCT\nspeed : INT;\nEND_STRUCT;\nEND_TYPE\n', + }) + + expect(store.getState().datatypeActions.rename('OldDT', 'NewDT').ok).toBe(true) + + const renamed = store.getState().project.data.dataTypes[0] + expect(renamed.name).toBe('NewDT') + expect(renamed.derivation === 'structure' && renamed.variable.map((v) => v.name)).toEqual(['speed']) + + const model = store.getState().editor + expect(model.type === 'plc-datatype' && model.structure.display === 'code' && model.structure.code).toContain( + 'NewDT : STRUCT', + ) + }) + + it('refuses the rename while the code view holds invalid text', () => { + store + .getState() + .editorActions.updateModelStructureForName('OldDT', { display: 'code', code: 'TYPE\ngarbage\nEND_TYPE\n' }) + + const result = store.getState().datatypeActions.rename('OldDT', 'NewDT') + expect(result.ok).toBe(false) + expect(store.getState().project.data.dataTypes[0].name).toBe('OldDT') + expect(store.getState().pendingDeletions).not.toContain('datatypes/OldDT.dt') + }) + it('rejects a name owned by an unreadable .dt file (case-insensitive)', () => { store .getState() @@ -1970,6 +1999,33 @@ describe('createSharedSlice', () => { expect(state.files['Configuration']).toBeDefined() }) + it('pre-opens an unreadable .dt file as a code-mode tab without stealing focus', () => { + const data = { + ...makeMinimalProjectResponse(), + unparsedDataTypeFiles: [ + { relativePath: 'datatypes/Broken.dt', content: 'TYPE\nBroken : STRUCT\ngarbage\nEND_TYPE\n' }, + // No name to derive — skipped rather than registered under ''. + { relativePath: '', content: 'orphan' }, + ], + } + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) + + const state = store.getState() + expect(state.unparsedDataTypeFiles).toHaveLength(2) + expect(state.files['Broken']).toEqual({ type: 'data-type', filePath: 'Broken', saved: true }) + expect(state.files['']).toBeUndefined() + expect(state.tabs.map((tab) => tab.name)).toEqual(['main', 'Broken']) + // Focus stays on the auto-opened POU. + expect(state.selectedTab).toBe('main') + + const model = state.editors.find((editor) => editor.meta.name === 'Broken') + expect(model?.type === 'plc-datatype' && model.meta.derivation).toBe('structure') + expect(model?.type === 'plc-datatype' && model.structure).toEqual({ + display: 'code', + code: 'TYPE\nBroken : STRUCT\ngarbage\nEND_TYPE\n', + }) + }) + it('logs warnings to console when present', () => { const data = { ...makeMinimalProjectResponse(), diff --git a/src/frontend/store/__tests__/shared-utils.test.ts b/src/frontend/store/__tests__/shared-utils.test.ts index 7a7a8b7de..580daf245 100644 --- a/src/frontend/store/__tests__/shared-utils.test.ts +++ b/src/frontend/store/__tests__/shared-utils.test.ts @@ -6,6 +6,7 @@ import { createEditorObjectForServer, createPouObject, createTabObject, + guessDatatypeDerivation, } from '../slices/shared/utils' describe('shared/utils', () => { @@ -236,7 +237,7 @@ describe('shared/utils', () => { expect(result).toEqual({ type: 'plc-datatype', meta: { name: 'IntArray', derivation: 'array' }, - structure: { description: '', selectedRow: '' }, + structure: { display: 'table', description: '', selectedRow: '-1' }, }) }) @@ -257,6 +258,23 @@ describe('shared/utils', () => { }) }) + // ------------------------------------------------------------------------- + // guessDatatypeDerivation + // ------------------------------------------------------------------------- + describe('guessDatatypeDerivation', () => { + it('detects a structure', () => { + expect(guessDatatypeDerivation('TYPE\nPoint : STRUCT\nx : INT;\nEND_STRUCT;\nEND_TYPE')).toBe('structure') + }) + + it('detects an array', () => { + expect(guessDatatypeDerivation('TYPE\nBuf : ARRAY [0..9] OF INT;\nEND_TYPE')).toBe('array') + }) + + it('falls back to enumerated', () => { + expect(guessDatatypeDerivation('TYPE\nColors : (RED, GREEN);\nEND_TYPE')).toBe('enumerated') + }) + }) + // ------------------------------------------------------------------------- // createEditorObjectForServer // ------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/tabs-utils.test.ts b/src/frontend/store/__tests__/tabs-utils.test.ts index 085be585b..926d2a6c8 100644 --- a/src/frontend/store/__tests__/tabs-utils.test.ts +++ b/src/frontend/store/__tests__/tabs-utils.test.ts @@ -94,7 +94,7 @@ describe('tabs/utils', () => { expect(result.type).toBe('plc-datatype') if (result.type === 'plc-datatype') { expect(result.meta.derivation).toBe('enumerated') - expect(result.structure).toEqual({ selectedRow: '-1', description: '' }) + expect(result.structure).toEqual({ display: 'table', selectedRow: '-1', description: '' }) } }) diff --git a/src/frontend/store/slices/editor/slice.ts b/src/frontend/store/slices/editor/slice.ts index 029fb66c5..7b71c7536 100644 --- a/src/frontend/store/slices/editor/slice.ts +++ b/src/frontend/store/slices/editor/slice.ts @@ -1,7 +1,25 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' -import type { EditorSlice, EditorState } from './types' +import type { EditorSlice, EditorState, StructureTableType } from './types' + +const applyStructureView = ( + current: StructureTableType, + data: { display?: 'code' | 'table'; selectedRow?: number; description?: string; code?: string }, +): StructureTableType => { + const display = data.display ?? current.display + if (display === 'table') { + const prevSelectedRow = current.display === 'table' ? current.selectedRow : '-1' + const prevDescription = current.display === 'table' ? current.description : '' + return { + display: 'table', + selectedRow: data.selectedRow !== undefined ? data.selectedRow.toString() : prevSelectedRow, + description: data.description ? data.description : prevDescription, + } + } + const existingCode = current.display === 'code' ? current.code : undefined + return { display: 'code', code: data.code !== undefined ? data.code : existingCode } +} export const createEditorSlice: StateCreator = (setState, getState) => ({ editors: [], @@ -159,19 +177,26 @@ export const createEditorSlice: StateCreator = }), ), - updateModelStructure: ({ selectedRow, description }) => + updateModelStructure: (data) => setState( produce((state: EditorState) => { const { editor } = state if (editor.type === 'plc-datatype') { - editor.structure = { - selectedRow: selectedRow !== undefined ? selectedRow.toString() : editor.structure.selectedRow, - description: description ? description : editor.structure.description, - } + editor.structure = applyStructureView(editor.structure, data) } }), ), + updateModelStructureForName: (name, data) => + setState( + produce((state: EditorState) => { + const targetEditor = + state.editor.meta.name === name ? state.editor : state.editors.find((e) => e.meta.name === name) + if (targetEditor?.type !== 'plc-datatype') return + targetEditor.structure = applyStructureView(targetEditor.structure, data) + }), + ), + updateModelLadder: ({ openRung }) => setState( produce((state: EditorState) => { diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index 42ad52b75..39786825c 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -25,10 +25,16 @@ export type GlobalVariablesTableType = code?: string } -export type StructureTableType = { - description: string - selectedRow: string -} +export type StructureTableType = + | { + display: 'table' + description: string + selectedRow: string + } + | { + display: 'code' + code?: string + } export type TaskType = { display: 'table'; selectedRow: string } | { display: 'code' } @@ -253,7 +259,17 @@ export type EditorActions = { code?: string }, ) => void - updateModelStructure: (data: { selectedRow?: number; description?: string }) => void + /** `display` is optional so table-mode row/description updates can't flip the view. */ + updateModelStructure: (data: { + display?: 'code' | 'table' + selectedRow?: number + description?: string + code?: string + }) => void + updateModelStructureForName: ( + name: string, + data: { display?: 'code' | 'table'; selectedRow?: number; description?: string; code?: string }, + ) => void updateModelTasks: (tasks: { selectedRow?: number; display: 'code' | 'table' }) => void updateModelInstances: (instances: { selectedRow?: number; display: 'code' | 'table' }) => void updateModelLadder: (data: { openRung?: { rungId: string; open: boolean } }) => void diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 1ed7b6605..169b171f4 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -36,6 +36,8 @@ import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' import { isLegalIdentifier } from '../../../utils/keywords' import { DEFAULT_BUFFER_MAPPING } from '../../../utils/modbus/generate-modbus-slave-config' +import { serializeDataTypeToText } from '../../../utils/PLC/data-type-serializer' +import { parseDataTypeFromText } from '../../../utils/PLC/data-type-text-parser' import { getExtensionFromLanguage, getFolderFromPouType } from '../../../utils/PLC/pou-file-extensions' import type { ProjectResponse, ProjectSlice, ProjectSliceRoot } from './types' import { getVariableBasedOnRowIdOrVariableId } from './utils' @@ -504,6 +506,59 @@ const regenerateVariablesText = (pouName: string | undefined, getState: ProjectG state.editorActions.updateModelVariablesForName(pouName, { display: 'code', code: newText }) } +// --------------------------------------------------------------------------- +// Data-type text ⇄ data-type form reconcile helpers +// --------------------------------------------------------------------------- +// +// Same contract as the variables pair above, for the per-type `.dt` +// code view. The form molecules only render in table mode, so the +// reachable divergence cases are a tree rename and an undo/disk +// revert landing while the type sits in code mode with a diverged +// buffer. Rename reconciles first (and refuses when the text is +// invalid, so uncommitted edits are never silently discarded); +// rename and snapshot restore both regenerate afterwards so Monaco +// shows the new state. + +const findDatatypeEditorCode = (name: string, getState: ProjectGetState): string | undefined => { + const state = getState() + const editorModel = state.editor.meta.name === name ? state.editor : state.editors.find((e) => e.meta.name === name) + if (editorModel?.type !== 'plc-datatype') return undefined + if (editorModel.structure.display !== 'code') return undefined + return editorModel.structure.code +} + +const reconcileDatatypeText = (name: string, getState: ProjectGetState, setState: ProjectSetState): ProjectResponse => { + const code = findDatatypeEditorCode(name, getState) + if (typeof code !== 'string') return ok() + + const current = getState().project.data.dataTypes.find((d) => d.name === name) + if (!current) return ok() + // Buffer is a verbatim serialisation of the current type — nothing to fold in. + if (code === serializeDataTypeToText(current)) return ok() + + const { dataType, error } = parseDataTypeFromText(code, name) + if (!dataType) return fail(error ?? 'Unknown parse error.', 'Data type text is invalid') + + setState( + produce((slice: ProjectSlice) => { + const idx = slice.project.data.dataTypes.findIndex((d) => d.name === name) + if (idx !== -1) slice.project.data.dataTypes[idx] = dataType + }), + ) + return ok() +} + +const regenerateDatatypeText = (name: string, getState: ProjectGetState): void => { + if (findDatatypeEditorCode(name, getState) === undefined) return + const state = getState() + const dataType = state.project.data.dataTypes.find((d) => d.name === name) + if (!dataType) return + state.editorActions.updateModelStructureForName(name, { + display: 'code', + code: serializeDataTypeToText(dataType), + }) +} + const createProjectSlice: StateCreator = (setState, getState) => ({ project: { meta: { name: '', type: 'plc-project', path: '' }, @@ -1124,7 +1179,10 @@ const createProjectSlice: StateCreator = if (idx !== -1) slice.project.data.dataTypes[idx] = data }), ) + regenerateDatatypeText(name, getState) }, + reconcileDatatypeText: (name) => reconcileDatatypeText(name, getState, setState), + regenerateDatatypeText: (name) => regenerateDatatypeText(name, getState), setUnparsedDataTypeFiles: (files) => { setState( produce((slice: ProjectSlice) => { @@ -1132,6 +1190,13 @@ const createProjectSlice: StateCreator = }), ) }, + removeUnparsedDataTypeFile: (relativePath) => { + setState( + produce((slice: ProjectSlice) => { + slice.unparsedDataTypeFiles = slice.unparsedDataTypeFiles.filter((f) => f.relativePath !== relativePath) + }), + ) + }, // ----------------------------------------------------------------------- // Tasks diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 5a1c1e8c2..015d48e5e 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -223,9 +223,16 @@ export type ProjectActions = { createArrayDimension: (args: { name: string; derivation: 'array' | 'enumerated' | 'structure' }) => void rearrangeStructureVariables: (args: { associatedDataType?: string; rowId: number; newIndex: number }) => void applyDatatypeSnapshot: (name: string, data: PLCDataType) => void + /** Fold a diverged `.dt` code buffer back into the type before an + * external mutation; refuses (`ok: false`) when the text is invalid. */ + reconcileDatatypeText: (name: string) => ProjectResponse + /** Re-serialize the type into its code buffer after an external mutation. */ + regenerateDatatypeText: (name: string) => void /** Stash raw `.dt` files that failed to parse on load so saves echo * them back verbatim (no silent data loss). */ setUnparsedDataTypeFiles: (files: RawProjectFile[]) => void + /** Drop a preserved raw file once its text parses and becomes a real type. */ + removeUnparsedDataTypeFile: (relativePath: string) => void // Tasks createTask: (dto: TaskDTO & { rowToInsert?: number }) => ProjectResponse diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 6922712c9..df6fa89b2 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -20,7 +20,13 @@ import { } from '../tabs/utils' import { cancelFlowWriteBacks, flushFlowWriteBacks } from './flow-writeback' import type { PouHistorySnapshot, SharedRootState, SharedSlice } from './types' -import { createDatatypeObject, createEditorObjectForDatatype, createEditorObjectForPou, createPouObject } from './utils' +import { + createDatatypeObject, + createEditorObjectForDatatype, + createEditorObjectForPou, + createPouObject, + guessDatatypeDerivation, +} from './utils' const MAX_HISTORY_SIZE = 50 @@ -318,12 +324,22 @@ const createSharedSlice: StateCreator = (s const datatype = state.project.data.dataTypes.find((d) => d.name === oldName) if (!datatype) return { ok: false, message: 'Data type not found' } - return renameElement(state, oldName, newName, () => { + // Fold pending code-view edits in first, so the rename doesn't + // regenerate over them. Invalid text blocks the rename instead + // of silently discarding what the user typed. + const reconcile = state.projectActions.reconcileDatatypeText(oldName) + if (!reconcile.ok) return { ok: false, message: reconcile.message } + + const result = renameElement(state, oldName, newName, () => { // Renames via the dedicated action so the old .dt path gets // queued for deletion — a plain updateDatatype would strand // the old file on disk. state.projectActions.updateDatatypeName(oldName, newName) }) + // After renameElement: both the type and its editor model are + // keyed by newName, so the buffer's TYPE line can be refreshed. + if (result.ok) getState().projectActions.regenerateDatatypeText(newName) + return result }, duplicate: (sourceName, newName) => { @@ -662,6 +678,15 @@ const createSharedSlice: StateCreator = (s // them back verbatim; always set so a reopen clears stale ones. getState().projectActions.setUnparsedDataTypeFiles(data.unparsedDataTypeFiles ?? []) + // No PLCDataType exists for an unreadable file, so it can't show + // up in the project tree. Collected here to get a file entry and a + // pre-opened code-mode tab further down — the only way the user + // can find and fix the declaration without leaving the editor. + const unparsedDataTypes = (data.unparsedDataTypeFiles ?? []).flatMap((file) => { + const name = file.relativePath.split('/').pop()?.replace(/\.dt$/i, '') + return name ? [{ name, content: file.content, derivation: guessDatatypeDerivation(file.content) }] : [] + }) + // Add ladder and FBD flows for graphical POUs. // // The flow object embeds its own `name` field — historically the @@ -870,6 +895,9 @@ const createSharedSlice: StateCreator = (s data.projectData.dataTypes.forEach((dt) => { files[dt.name] = { type: 'data-type', filePath: dt.name, saved: true } }) + unparsedDataTypes.forEach(({ name }) => { + files[name] = { type: 'data-type', filePath: name, saved: true } + }) const servers = data.projectData.servers if (servers) { servers.forEach((s) => { @@ -971,6 +999,20 @@ const createSharedSlice: StateCreator = (s } }) + // Same idea for unreadable .dt files, except the tab has to be + // created too: without a PLCDataType there is no tree leaf to + // click. Focus stays on the auto-opened POU above. + unparsedDataTypes.forEach(({ name, content, derivation }) => { + const tabToBeCreated: TabsProps = { + name, + path: `/data/data-types/${derivation}/${name}`, + elementType: { type: 'data-type', derivation }, + } + getState().tabsActions.updateTabs(tabToBeCreated) + getState().editorActions.addModel(createEditorObjectForDatatype(name, derivation)) + getState().editorActions.updateModelStructureForName(name, { display: 'code', code: content }) + }) + // Reset all graphical flow updated flags at the very end of project open. // Various operations during load (syncNodesWithVariables, debug flag restoration, // tab opening) call updateNode which sets flow.updated = true as a side effect. diff --git a/src/frontend/store/slices/shared/utils.ts b/src/frontend/store/slices/shared/utils.ts index d9e7e1bff..71efb9385 100644 --- a/src/frontend/store/slices/shared/utils.ts +++ b/src/frontend/store/slices/shared/utils.ts @@ -141,10 +141,20 @@ export function createEditorObjectForDatatype(name: string, derivation: string): return { type: 'plc-datatype', meta: { name, derivation: derivation as 'enumerated' | 'structure' | 'array' }, - structure: { description: '', selectedRow: '' }, + structure: { display: 'table', description: '', selectedRow: '-1' }, } } +/** + * Best-effort derivation for a `.dt` file that failed to parse — it only + * picks the tab/tree icon, since no `PLCDataType` exists to read it from. + */ +export function guessDatatypeDerivation(content: string): 'enumerated' | 'structure' | 'array' { + if (/\bSTRUCT\b/i.test(content)) return 'structure' + if (/\bARRAY\b/i.test(content)) return 'array' + return 'enumerated' +} + export function createEditorObjectForServer( name: string, protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua', diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index bb04f5098..f3e13db48 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -57,7 +57,7 @@ const CreateEditorModelObject = ( return { type: 'plc-datatype', meta: { name, derivation }, - structure: { selectedRow: '-1', description: '' }, + structure: { display: 'table', selectedRow: '-1', description: '' }, } } From 29c815990f0ccd2d9b221474577d9c9aad24b9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 6 Aug 2026 16:31:05 -0300 Subject: [PATCH 46/79] fix(datatypes): reject concurrent rename requests and handle rename rejections Review findings from CodeRabbit on this PR: - A second rename arriving while one awaits the impact modal used to overwrite pendingDatatypeRename, dropping the first resolver and stranding its await forever (Enter + blur can double-fire). The second request is now rejected explicitly. - Both rename callers consumed the promise with .then only; an unexpected rejection would skip the recovery path and leave the name input stuck in edit mode. Both now catch, restore the previous name, and surface the error. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Br23N5eg2d4ASekgSmTFzz --- .../_features/[workspace]/data-type/index.tsx | 25 ++++++++++++------- .../_molecules/project-tree/index.tsx | 8 +++--- .../store/__tests__/shared-slice.test.ts | 22 ++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 5 ++++ 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index 665c32321..6ccd52c2b 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -3,6 +3,7 @@ import { ComponentPropsWithoutRef, useEffect, useState } from 'react' import type { PLCDataType } from '../../../../../middleware/shared/ports/types' import { useOpenPLCStore } from '../../../../store' import { extractSearchQuery } from '../../../../store/slices/search/utils' +import { getErrorMessage } from '../../../../utils/get-error-message' import { InputWithRef } from '../../../_atoms/input' import { ArrayDataType } from '../../../_molecules/data-types/array' import { EnumeratorDataType } from '../../../_molecules/data-types/enumerated' @@ -54,16 +55,22 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { // `datatypeActions.rename` validates the new name and rekeys the // editor model, tab, and file entry, then flags the file dirty. // Async: a referenced type awaits the impact modal first. - void rename(dataTypeName, value).then((result) => { - if (!result.ok) { - setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) - // A declined impact modal is a user choice, not a failure. - if (!result.cancelled) { - toast({ title: 'Rename failed', description: result.message, variant: 'fail' }) + void rename(dataTypeName, value) + .then((result) => { + if (!result.ok) { + setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) + // A declined impact modal is a user choice, not a failure. + if (!result.cancelled) { + toast({ title: 'Rename failed', description: result.message, variant: 'fail' }) + } } - } - setIsEditing(false) - }) + setIsEditing(false) + }) + .catch((error: unknown) => { + setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) + toast({ title: 'Rename failed', description: getErrorMessage(error), variant: 'fail' }) + setIsEditing(false) + }) } } diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index a770d0057..bb152e3f1 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -595,9 +595,11 @@ const ProjectTreeLeaf = ({ if (isDatatype) { // Async: a referenced type awaits the impact modal before renaming. - void renameDatatype(label, newLabel).then((res) => { - if (!res.ok) setNewLabel(label || '') - }) + void renameDatatype(label, newLabel) + .then((res) => { + if (!res.ok) setNewLabel(label || '') + }) + .catch(() => setNewLabel(label || '')) return } diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 84367e05d..3de38b3b3 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -806,6 +806,28 @@ describe('createSharedSlice', () => { expect(store.getState().pendingDatatypeRename).toBeNull() }) + it('rejects a second rename while one is awaiting confirmation', async () => { + const first = store.getState().datatypeActions.rename('OldDT', 'NewDT') + const pendingBefore = store.getState().pendingDatatypeRename + + store.getState().datatypeActions.create({ name: 'Other', derivation: 'structure' }) + store.getState().projectActions.updateDatatype('Other', { + name: 'Other', + derivation: 'structure', + variable: [{ name: 'f', type: { definition: 'user-data-type', value: 'Chassis' } }], + }) + const second = await store.getState().datatypeActions.rename('Chassis', 'Frame') + + expect(second.ok).toBe(false) + expect(second.message).toBe('Another data type rename is awaiting confirmation') + // The first request's resolver is untouched and still completes. + expect(store.getState().pendingDatatypeRename).toBe(pendingBefore) + store.getState().datatypeActions.respondToPendingRename(true) + const result = await first + expect(result).toEqual({ ok: true }) + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toContain('NewDT') + }) + it('respondToPendingRename without a pending request is a no-op', () => { const before = store.getState() store.getState().datatypeActions.respondToPendingRename(true) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index a01c5963a..57b5ddc4a 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -370,6 +370,11 @@ const createSharedSlice: StateCreator = (s state.project.data.dataTypes, ) if (impact.totalReferences > 0) { + // Overwriting a pending request would drop its resolver and strand + // the first caller's await forever (e.g. Enter + blur double-fire). + if (getState().pendingDatatypeRename) { + return { ok: false, message: 'Another data type rename is awaiting confirmation' } + } const confirmed = await new Promise((resolve) => { setState({ pendingDatatypeRename: { oldName, newName, impact, resolve } }) }) From 8c419a1dcdb868805b08c1c686dabf6da8d28b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 6 Aug 2026 16:58:35 -0300 Subject: [PATCH 47/79] fix(data-types): make a failed code commit fire once, and keep unreadable .dt files off taken names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking away from the code view raises document `mousedown` and then container `focusout`. The commit is synchronous, so `isParsingRef` is already clear by the second event, and a failure left `lastParsedCodeRef` un-advanced — so the same invalid buffer was parsed twice and toasted twice. Track the rejected buffer as well, so the pair is one attempt whatever its outcome. The variables editor never hit this because its commit is async and its latch spans both events. An unreadable `datatypes/.dt` took its name from the file basename with nothing checking it against the elements already loaded. POUs and data types share one identifier namespace and the file registry is keyed by raw name, so a project holding both a POU `foo` and an unreadable `foo.dt` had that POU's registry entry retyped to `data-type` — enough to send its next save down the `.dt` branch. Skip colliding names; the file still rides along in `unparsedDataTypeFiles`, so it is echoed back to disk rather than dropped. Comments across the feature trimmed to the non-obvious constraints. Refs DOPE-535 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GvGrhB1LyJz9MBwYhjoBDY --- .../_features/[workspace]/data-type/index.tsx | 37 ++++++++++--------- .../store/__tests__/shared-slice.test.ts | 26 +++++++++++++ src/frontend/store/slices/project/slice.ts | 13 +------ src/frontend/store/slices/shared/slice.ts | 24 ++++++------ src/frontend/store/slices/shared/utils.ts | 5 +-- 5 files changed, 60 insertions(+), 45 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index fc46a36c0..44190d68a 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -40,17 +40,15 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() - // Every open data type is mounted at once (workspace-screen keeps the - // inactive ones hidden), so the view state has to come from this - // type's own model — never from the active `editor`. + // Every open data type is mounted at once, so the view state comes from + // this type's own model — never from the active `editor`. const model = editor.meta.name === dataTypeName ? editor : editors.find((e) => e.meta.name === dataTypeName) const modelStructure = model?.type === 'plc-datatype' ? model.structure : undefined const codeViewEnabled = isDataTypeFilesEnabled() const display = codeViewEnabled && modelStructure?.display === 'code' ? 'code' : 'table' const modelCode = modelStructure?.display === 'code' ? modelStructure.code : undefined - // A `.dt` file that failed to parse has no entry in `dataTypes`; the - // raw text is all there is until the user fixes it. + // An unparseable file has no entry in `dataTypes` — raw text is all there is. const rawFile = unparsedDataTypeFiles.find( (file) => file.relativePath.split('/').pop()?.replace(/\.dt$/i, '') === dataTypeName, ) @@ -68,6 +66,7 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { const latestCodeRef = useRef(editorCode) const latestDisplayRef = useRef(display) const lastParsedCodeRef = useRef(editorCode) + const lastRejectedCodeRef = useRef(null) const lastMirroredCodeRef = useRef(editorCode) const isParsingRef = useRef(false) const commitCodeRef = useRef<() => boolean>(() => false) @@ -77,20 +76,17 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { if (dataType) setEditorContent(dataType) }, [dataTypes, dataTypeName]) - // Keep the buffer serialized from the form while in table mode, so the - // toggle already holds the right text the moment it flips. + // In table mode the form is the committed state: it seeds both the buffer + // and the watermark, so the toggle is instant and can't commit a no-op. useEffect(() => { if (display === 'code') return const text = editorContent ? serializeDataTypeToText(editorContent) : (rawFile?.content ?? '') setEditorCode(text) - // In table mode the form is the committed state, so this is also - // the watermark the next outside-click compares against. lastParsedCodeRef.current = text }, [editorContent, display, rawFile?.content]) - // Adopt buffers written by the store (a tree rename or an undo - // regenerates them), but never the echo of our own mirror below — - // that would race the keystroke that produced it. + // Adopt store-written buffers (rename, undo), never the echo of our own + // mirror below — that would race the keystroke that produced it. useEffect(() => { if (display !== 'code' || typeof modelCode !== 'string') return if (modelCode === lastMirroredCodeRef.current) return @@ -116,8 +112,7 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { } }, [dataTypeName, updateModelStructureForName]) - // A type that doesn't exist yet is a broken file on disk: show why it - // is broken while the user edits, instead of only on commit. + // No type yet means a broken file — surface why while editing, not on commit. useEffect(() => { if (display !== 'code' || editorContent) return setParseError(parseDataTypeFromText(editorCode, dataTypeName).error ?? null) @@ -159,11 +154,20 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { useEffect(() => { if (display !== 'code') return + // Clicking away raises mousedown then focusout, and the commit is + // synchronous, so `isParsingRef` is clear by the second one. Both + // watermarks make the pair one attempt whatever its outcome. const tryCommit = () => { if (isParsingRef.current) return if (editorCode === lastParsedCodeRef.current) return + if (editorCode === lastRejectedCodeRef.current) return isParsingRef.current = true - if (commitCodeRef.current()) lastParsedCodeRef.current = editorCode + if (commitCodeRef.current()) { + lastParsedCodeRef.current = editorCode + lastRejectedCodeRef.current = null + } else { + lastRejectedCodeRef.current = editorCode + } isParsingRef.current = false } @@ -173,8 +177,7 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { tryCommit() } - // Covers keyboard navigation, Tab and shortcuts — anything that - // moves focus away without a mousedown. + // Covers focus moves with no mousedown: Tab, shortcuts. const onFocusOut = (e: FocusEvent) => { if (!containerRef.current) return const newTarget = e.relatedTarget as Node | null diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 00271ba36..066e640e9 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -2026,6 +2026,32 @@ describe('createSharedSlice', () => { }) }) + it('does not let an unreadable .dt displace a POU or a parsed type of the same name', () => { + const data = makeMinimalProjectResponse() + data.projectData.dataTypes = [ + { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' }, + ] as typeof data.projectData.dataTypes + const withCollisions = { + ...data, + unparsedDataTypeFiles: [ + // Case-insensitive: the filesystem folds case, the registry doesn't. + { relativePath: 'datatypes/MAIN.dt', content: 'TYPE\ngarbage\nEND_TYPE\n' }, + { relativePath: 'datatypes/colors.dt', content: 'TYPE\ngarbage\nEND_TYPE\n' }, + ], + } + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(withCollisions) + + const state = store.getState() + // The POU keeps its own registry entry, tab and model. + expect(state.files['main'].type).toBe('program') + expect(state.tabs.map((tab) => tab.name)).toEqual(['main']) + expect(state.editors.every((editor) => editor.type !== 'plc-datatype')).toBe(true) + expect(state.files['MAIN']).toBeUndefined() + expect(state.files['colors']).toBeUndefined() + // Still preserved, so the next save echoes both files back verbatim. + expect(state.unparsedDataTypeFiles).toHaveLength(2) + }) + it('logs warnings to console when present', () => { const data = { ...makeMinimalProjectResponse(), diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 169b171f4..21a00faf0 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -506,18 +506,7 @@ const regenerateVariablesText = (pouName: string | undefined, getState: ProjectG state.editorActions.updateModelVariablesForName(pouName, { display: 'code', code: newText }) } -// --------------------------------------------------------------------------- -// Data-type text ⇄ data-type form reconcile helpers -// --------------------------------------------------------------------------- -// -// Same contract as the variables pair above, for the per-type `.dt` -// code view. The form molecules only render in table mode, so the -// reachable divergence cases are a tree rename and an undo/disk -// revert landing while the type sits in code mode with a diverged -// buffer. Rename reconciles first (and refuses when the text is -// invalid, so uncommitted edits are never silently discarded); -// rename and snapshot restore both regenerate afterwards so Monaco -// shows the new state. +// Same contract as the variables pair above, for the `.dt` code view. const findDatatypeEditorCode = (name: string, getState: ProjectGetState): string | undefined => { const state = getState() diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index df6fa89b2..1435e28ab 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -325,8 +325,7 @@ const createSharedSlice: StateCreator = (s if (!datatype) return { ok: false, message: 'Data type not found' } // Fold pending code-view edits in first, so the rename doesn't - // regenerate over them. Invalid text blocks the rename instead - // of silently discarding what the user typed. + // regenerate over them. const reconcile = state.projectActions.reconcileDatatypeText(oldName) if (!reconcile.ok) return { ok: false, message: reconcile.message } @@ -336,8 +335,7 @@ const createSharedSlice: StateCreator = (s // the old file on disk. state.projectActions.updateDatatypeName(oldName, newName) }) - // After renameElement: both the type and its editor model are - // keyed by newName, so the buffer's TYPE line can be refreshed. + // Only after renameElement are the type and its model both keyed by newName. if (result.ok) getState().projectActions.regenerateDatatypeText(newName) return result }, @@ -678,13 +676,17 @@ const createSharedSlice: StateCreator = (s // them back verbatim; always set so a reopen clears stale ones. getState().projectActions.setUnparsedDataTypeFiles(data.unparsedDataTypeFiles ?? []) - // No PLCDataType exists for an unreadable file, so it can't show - // up in the project tree. Collected here to get a file entry and a - // pre-opened code-mode tab further down — the only way the user - // can find and fix the declaration without leaving the editor. + // Unreadable files have no PLCDataType, so no tree leaf to click. const unparsedDataTypes = (data.unparsedDataTypeFiles ?? []).flatMap((file) => { const name = file.relativePath.split('/').pop()?.replace(/\.dt$/i, '') - return name ? [{ name, content: file.content, derivation: guessDatatypeDerivation(file.content) }] : [] + if (!name) return [] + // The file registry is keyed by raw name across both kinds: a + // colliding file would retype the real element and misroute its save. + const taken = [...data.projectData.pous, ...data.projectData.dataTypes].some( + (element) => element.name.toLowerCase() === name.toLowerCase(), + ) + if (taken) return [] + return [{ name, content: file.content, derivation: guessDatatypeDerivation(file.content) }] }) // Add ladder and FBD flows for graphical POUs. @@ -999,9 +1001,7 @@ const createSharedSlice: StateCreator = (s } }) - // Same idea for unreadable .dt files, except the tab has to be - // created too: without a PLCDataType there is no tree leaf to - // click. Focus stays on the auto-opened POU above. + // Tab included, and focus stays on the auto-opened POU above. unparsedDataTypes.forEach(({ name, content, derivation }) => { const tabToBeCreated: TabsProps = { name, diff --git a/src/frontend/store/slices/shared/utils.ts b/src/frontend/store/slices/shared/utils.ts index 71efb9385..5972bbb91 100644 --- a/src/frontend/store/slices/shared/utils.ts +++ b/src/frontend/store/slices/shared/utils.ts @@ -145,10 +145,7 @@ export function createEditorObjectForDatatype(name: string, derivation: string): } } -/** - * Best-effort derivation for a `.dt` file that failed to parse — it only - * picks the tab/tree icon, since no `PLCDataType` exists to read it from. - */ +/** Best-effort derivation for an unparseable `.dt` — only picks the icon. */ export function guessDatatypeDerivation(content: string): 'enumerated' | 'structure' | 'array' { if (/\bSTRUCT\b/i.test(content)) return 'structure' if (/\bARRAY\b/i.test(content)) return 'array' From a47552e35fd4c983e46cb3f2f3297efbbe47d1ab Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 4 Aug 2026 19:50:02 -0400 Subject: [PATCH 48/79] fix(runtime-v4): report a switch in STOP as a warning, not a failed upload Uploading with the hardware mode switch in STOP ended with: Compilation completed successfully (exit code: 0). Failed to start PLC: START:ERROR_SWITCH_STOP Failed to upload to runtime. Stopping compilation process. All three lines after the first are wrong. The program did reach the device and compiled there; the runtime simply declined to start it, which is what a mode switch in STOP is for. Leaving the switch there while uploading is a normal thing to do, so the message should not send anyone looking for a problem. startPlcAfterBuild now recognises ERROR_SWITCH_STOP as its own outcome and explains it as a warning: "Program uploaded. The PLC was not started because the mode switch is in STOP -- move it to RUN to start." It is not retried either; nothing changes until a human moves the switch, so the 5 s BUSY loop had no business spinning on it. deployRuntimeProgram maps that to UPLOADED_NOT_STARTED, and both platform adapters treat it as a successful upload. Whether an outcome means "the program reached the device" now lives in one shared predicate, deployReachedDevice(), rather than being re-derived as `outcome === 'STARTED'` in each adapter -- the editor had it in two places and web in a third, which is how they would drift. Not addressed here, but noticed: START_TIMEOUT still maps to a failed upload, so a runtime that stays BUSY past the deadline reports "Failed to upload to runtime" after logging a warning that says otherwise. Same shape of bug, different trigger. --- .../compiler/editor-compiler-platform-port.ts | 14 ++++++++++--- .../__tests__/start-plc-after-build.test.ts | 19 +++++++++++++++++ .../shared/library/deploy-runtime-program.ts | 21 +++++++++++++++++++ .../shared/library/start-plc-after-build.ts | 19 ++++++++++++++++- 4 files changed, 69 insertions(+), 4 deletions(-) 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')) { From 20ff67e0e8315c109a808762f01993df1b421dee Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 4 Aug 2026 22:32:35 -0400 Subject: [PATCH 49/79] fix(runtime-v4): block run/stop while the runtime is mid-transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRANSITIONING means a start or stop is already underway: the runtime answers COMMAND:BUSY to everything except PING and STATUS, and the state it will settle on is not decided yet — so the icon is drawn from a state that is about to change and a click cannot do what it appears to. Folded into the existing plcControlBlocked / plcControlBlockedReason pair rather than added as a second mechanism, so the tooltip explains this the same way it explains a missing connection: 'PLC is changing state...'. handlePlcControl carries the same guard. Status arrives by poll, so a render can be up to one interval stale; the guard closes the window where the button still looks live and covers callers that are not the click. --- .../workspace-activity-bar/default.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index f50a79eac..e2b86ef82 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -104,11 +104,21 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // reason, and says so. `handlePlcControl` refuses such a target anyway, so // without this the button looked live and the click did nothing at all — no // command, no error, no log line. + // + // A transition already in flight blocks it for a third reason. TRANSITIONING + // means the runtime has a start or stop underway: it answers COMMAND:BUSY to + // everything except PING and STATUS, and the state it will settle on is not + // decided yet, so the icon is drawn from a state that is about to change. + // Clicking then cannot do what it appears to. const plcStateControlSupported = resolveTargetCapabilities(currentBoardInfo).plcStateControl - const plcControlBlocked = !plcStateControlSupported || deviceConnectionStatus !== 'connected' - const plcControlBlockedReason = plcStateControlSupported - ? 'Connect to the target first' - : 'This target does not support Start/Stop from the editor' + const plcTransitioning = plcStatus === 'TRANSITIONING' + const plcControlBlocked = + !plcStateControlSupported || deviceConnectionStatus !== 'connected' || plcTransitioning + const plcControlBlockedReason = !plcStateControlSupported + ? 'This target does not support Start/Stop from the editor' + : plcTransitioning + ? 'PLC is changing state...' + : 'Connect to the target first' // The emulator stopping is a session ending, and a debug session riding it ends // with it — which the drop handler below already does for every target. This @@ -547,6 +557,11 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // `switchPosition` in the store, so the pre-check is a store lookup rather than // another round trip over a medium the poll is already using. try { + // The button is disabled while a transition is in flight; this covers the + // window before the next status poll catches up, and any caller that is not + // the click. + if (plcStatus === 'TRANSITIONING') return + const wantRun = plcStatus !== 'RUNNING' // Never send a start to a device whose switch reads STOP. `null` means From dfef0a391f1fd51e21e8dbe0fb6a1baa980a9a4f Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 6 Aug 2026 16:40:15 -0400 Subject: [PATCH 50/79] fix(device): restore the Connect button's font size and drop the duplicate status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button rendered noticeably larger and heavier than the buttons beside it. The cause is cn(): it runs twMerge, which does not know this project's custom cp-* font scale, so it read `text-cp-sm` as conflicting with `text-white` and dropped the size class entirely. The button fell back to the browser default -- larger than the 10px intended AND larger than its 14px neighbours. Sibling brand buttons on this screen escape it only because they pass a plain string rather than cn(). Now h-8/text-sm, matching those siblings, and a size twMerge understands. Also removes both redundant "Connected" labels. The button label already is the status: "Disconnect" can only be shown while connected, "Connect" only while disconnected, so the green "● Connected" beside it and the grey "Connected" after it said the same thing twice more. "PLC: RUNNING" stays -- that is the program's state, which the button says nothing about. "● Connection failed" stays too: the button reads "Connect" whether the last attempt failed or never happened, so that one carries information the label does not. --- .../editor/device/configuration/board.tsx | 22 +++---------------- .../__tests__/device-connect-button.test.tsx | 9 ++++---- .../device-connect-button/index.tsx | 8 +++++-- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 6a14c8240..51aaf936c 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -27,15 +27,6 @@ import { ScanCycleStats } from '../../../../../_molecules/scan-cycle-stats' import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-editor-slot' import { PinMappingTable } from './components/pin-mapping-table' -/** - * Confirms the held device link on the device screen: a quiet, monochrome line - * that appears once Connect has settled on a channel a firmware answered. - */ -function DeviceConnectedIndicator({ isConnected }: { isConnected: boolean }) { - if (!isConnected) return null - return Connected -} - const Board = memo(function () { const capabilities = useCapabilities() const device = useDevice() @@ -634,13 +625,8 @@ const Board = memo(function () { onConnect={handleConnectToRuntime} onDisconnect={handleConnectToRuntime} > - {connectionStatus === 'connected' && ( - <> - {plcStatus && ( - | PLC: {plcStatus} - )} - - + {connectionStatus === 'connected' && plcStatus && ( + PLC: {plcStatus} )} @@ -718,9 +704,7 @@ const Board = memo(function () { {...(!isConnected && !communicationPort && !modbusTcpConfigured ? { blockedReason: 'Select a communication port first' } : {})} - > - - + /> ) : null} {!isOpenPLCRuntimeTarget(currentBoardInfo) && !isSimulatorTarget(currentBoardInfo) && ( diff --git a/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx index 6fc9bdc76..e34c4ff6c 100644 --- a/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx +++ b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx @@ -30,11 +30,12 @@ describe('DeviceConnectButton', () => { expect(onConnect).not.toHaveBeenCalled() }) - it('confirms a live connection on screen', () => { - // The baremetal copy never showed this, so a connected device looked the same - // as a disconnected one apart from the button label. + it('says Disconnect when connected, and nothing else', () => { + // The label IS the status: "Disconnect" can only appear while connected, so a + // separate "Connected" badge beside it was saying the same thing twice. render() - expect(screen.getByText('● Connected')).not.toBeNull() + expect(screen.getByRole('button').textContent).toBe('Disconnect') + expect(screen.queryByText(/Connected/)).toBeNull() }) it('reports a failed attempt', () => { diff --git a/src/frontend/components/_molecules/device-connect-button/index.tsx b/src/frontend/components/_molecules/device-connect-button/index.tsx index 796c59ac4..90c38f0ec 100644 --- a/src/frontend/components/_molecules/device-connect-button/index.tsx +++ b/src/frontend/components/_molecules/device-connect-button/index.tsx @@ -51,15 +51,19 @@ const DeviceConnectButton = ({ onClick={isConnected ? onDisconnect : onConnect} disabled={disabled} title={blockedReason ?? (isConnected ? 'Disconnect from the device' : 'Connect to the device')} + // text-sm, not the project's text-cp-sm: cn() runs twMerge, which does not + // know the custom cp-* font scale and therefore treated `text-cp-sm` as + // conflicting with `text-white`, dropping the size entirely. The button fell + // back to the browser default and rendered larger than its own neighbours. + // h-8/text-sm also matches the sibling brand buttons on this screen. className={cn( - 'h-[30px] rounded-md bg-brand px-4 py-1 font-caption text-cp-sm font-medium text-white', + 'h-8 rounded-md bg-brand px-4 font-caption text-sm font-medium text-white', 'hover:bg-brand-medium-dark disabled:opacity-50', )} > {isConnecting ? 'Connecting...' : isConnected ? 'Disconnect' : 'Connect'} - {isConnected && ● Connected} {status === 'error' && ● Connection failed} {children}
From a45b99c0af79874d0e2b0512383773a96569387d Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 6 Aug 2026 16:50:53 -0400 Subject: [PATCH 51/79] fix(ui): teach cn() the cp-* font scale instead of resizing the button My previous attempt made the Connect button WORSE, not better. The diagnosis was right -- twMerge silently drops `text-cp-sm` because it cannot tell a custom size from a text colour, so `text-cp-sm text-white` keeps only the colour -- but the fix was wrong: I moved the button to text-sm (14px) when main renders it at text-cp-sm (10px). It stayed too big, just for a new reason. main gets away with the same class string only because it passes a plain string instead of calling cn(). So the bug is in cn(), not in any one button: extendTailwindMerge now declares cp-xs / cp-sm / cp-base as font sizes, and `text-cp-sm text-white` keeps both. Size-versus-size still dedupes correctly. The buttons are back to main's exact classes, which now render as intended. This also silently repairs every other call site that puts a cp-* size and a text colour through cn() -- there is no warning when it happens, only a wrong size. --- .../device-connect-button/index.tsx | 11 ++++----- src/frontend/utils/cn.ts | 24 ++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/frontend/components/_molecules/device-connect-button/index.tsx b/src/frontend/components/_molecules/device-connect-button/index.tsx index 90c38f0ec..f2006a0c3 100644 --- a/src/frontend/components/_molecules/device-connect-button/index.tsx +++ b/src/frontend/components/_molecules/device-connect-button/index.tsx @@ -51,13 +51,12 @@ const DeviceConnectButton = ({ onClick={isConnected ? onDisconnect : onConnect} disabled={disabled} title={blockedReason ?? (isConnected ? 'Disconnect from the device' : 'Connect to the device')} - // text-sm, not the project's text-cp-sm: cn() runs twMerge, which does not - // know the custom cp-* font scale and therefore treated `text-cp-sm` as - // conflicting with `text-white`, dropping the size entirely. The button fell - // back to the browser default and rendered larger than its own neighbours. - // h-8/text-sm also matches the sibling brand buttons on this screen. + // Same classes main used for this button. They render correctly again now + // that cn() knows the cp-* font scale -- before that, twMerge dropped + // `text-cp-sm` as a conflict with `text-white` and the button jumped to the + // browser default size. className={cn( - 'h-8 rounded-md bg-brand px-4 font-caption text-sm font-medium text-white', + 'h-[30px] rounded-md bg-brand px-4 py-1 font-caption text-cp-sm font-medium text-white', 'hover:bg-brand-medium-dark disabled:opacity-50', )} > diff --git a/src/frontend/utils/cn.ts b/src/frontend/utils/cn.ts index c8498c564..7916262b7 100644 --- a/src/frontend/utils/cn.ts +++ b/src/frontend/utils/cn.ts @@ -1,4 +1,26 @@ import { ClassValue, clsx } from 'clsx' -import { twMerge } from 'tailwind-merge' +import { extendTailwindMerge } from 'tailwind-merge' + +/** + * Class merger that knows this project's custom `cp-*` font scale. + * + * Plain `twMerge` does not. Faced with `text-cp-sm text-white` it cannot tell that + * the first is a size, assumes both are text colours, and keeps only the last -- + * silently dropping the size. Anything styled that way rendered at the browser + * default instead: the Connect button came out visibly larger than its + * neighbours, with no warning and no build error. Buttons that escaped it did so + * only by passing a plain string instead of calling this. + * + * Declaring the scale here fixes every call site at once, rather than each caller + * having to know that its size class might evaporate. Keep this list in step with + * `fontSize` in tailwind.config.ts. + */ +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + 'font-size': [{ text: ['cp-xs', 'cp-sm', 'cp-base'] }], + }, + }, +}) export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)) From 92536a5062c2ec3dca63665b469b2a55dc76e0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 7 Aug 2026 12:31:38 +0200 Subject: [PATCH 52/79] feature(RTOP-193): VPP licensing flow ported onto the baremetal connection Ports the device-licensing flow from feat/always-on-debugger onto development, which had already evolved past that branch: the transport interfaces (DeviceChannelTransport / DeviceModbusTransport), run/stop over FC 0x4b and the held-session manager all postdate it. So this is additive over those abstractions rather than a cherry-pick, which would have reverted them. Protocol - license-blob.ts: the 98-byte lic_blob_t codec (little-endian content, crc32 ISO-HDLC over 0..93), cross-pinned to the C struct by a golden vector. - FC 0x49 write-license / 0x4A read-license: builders, parsers and the LIC_EMPTY / LIC_CORRUPT / LIC_UNSUPPORTED status codes. - Implemented on all four transports (serial, TCP, simulator, runtime-v4 WS). Serial framing The RTU client closed a frame after 10 ms of silence, which truncates a 98-byte blob at 9600 baud: the serial layer delivers it in chunks whose gaps exceed the idle window. Added a size-aware end-of-frame predictor and applied it to getVariablesList too, which had the same latent bug. Flow - deriveDeviceId = sha256("openplc-dev-v1|" || anchor)[:16], lowercase hex -- the key autonomy-edge stores a purchase against. - verifyStoredLicenseBlob: a 0x7E status does NOT mean the stored licence is good (the two targets check different things), so the bytes are verified and a blob that fails falls through to recovery -- the only automatic repair there is. - Never trust the write: 0x49 only stores bytes, so the flow re-reads and re-verifies before asserting possession. - An empty hardware anchor is refused before deriving anything: sha256(prefix || nothing) is a constant, so every board without a unique id would share one device id. Outcome union licensed / unlicensed / unsupported / check-failed as one discriminated union carried unchanged from the device to the badge. `unlicensed` carries `entitlementChecked`, because "the backend says there is no purchase" (offer to buy) and "nobody asked" (offer a re-check) are different promises to a customer, and collapsing either into check-failed is what tells someone who already paid to pay again. Backend client POST {edge}/vpp-licenses/activate with { deviceId, packageId }. The dev mock signer is deliberately absent: it minted device-bound blobs a board accepts as FULL, and the local test path now runs the real backend against the real per-VPP key. Scheme-driven http/https selection (http-module.ts) makes OPENPLC_EDGE_API_URL=http://localhost:3333 actually work -- it also fixes the same hardcoded https in the catalog transport. IPC device:read-license (read + verify, local, cheap) and device:refresh-license (full flow, may reach the network) over the HELD link, deliberately not folded into device:connect: a connect must not block on HTTP, and a purchase happens after the user has been told they are in demo mode. Guarded by a sequence mutex -- the frame mutex cannot see a compound read/HTTP/write/read sequence. Firmware license_blob.h, license_store.h + weak fallback, license_gate.h + weak fallback, the two Modbus handlers, and the boot gate. The split: the closed license-core verifies and gates I/O, the VPP ships the storage backend, the open firmware only moves bytes. The anchor is NOT passed to the gate -- the core reads the silicon itself, so the open firmware cannot claim an identity. And there is no weak fallback for updateInput/OutputBuffers, so dropping the license-core from a licensable VPP fails to LINK instead of silently running unenforced I/O. debugWriteLicense also closes a network-reachable overread: `len` came off the wire unchecked, and Modbus TCP never cross-checked it against the frame size, so a 6-byte packet declaring len=0xFFFF read ~64 KB past mb_frame. Not verified in this commit Nothing here has been compiled for a board. Whether the sketch builds with the new includes, and whether the injected license_store_* overrides the weak default at link time, are open until a real flash. Co-Authored-By: Claude Opus 5 --- .../webpack/webpack.config.renderer.dev.ts | 5 + .../webpack/webpack.config.renderer.prod.ts | 4 + resources/sources/Baremetal/Baremetal.ino | 37 ++ resources/sources/Baremetal/license_blob.h | 93 ++++ resources/sources/Baremetal/license_gate.h | 80 ++++ .../sources/Baremetal/license_gate_weak.cpp | 41 ++ resources/sources/Baremetal/license_store.h | 64 +++ .../sources/Baremetal/license_store_weak.cpp | 19 + resources/sources/Baremetal/modbus_debug.cpp | 86 +++- resources/sources/Baremetal/modbus_debug.h | 6 +- resources/sources/Baremetal/modbus_pdu.cpp | 22 + resources/sources/Baremetal/modbus_types.h | 11 + resources/sources/arduino/openplc.h | 25 ++ .../editor/compiler/compiler-module.ts | 54 +++ .../desktop-catalog-transport.ts | 6 +- .../license/__tests__/device-identity.test.ts | 34 ++ .../license-activation-client.test.ts | 262 +++++++++++ .../license/__tests__/license-flow.test.ts | 425 ++++++++++++++++++ src/backend/editor/license/device-identity.ts | 72 +++ .../license/license-activation-client.ts | 200 +++++++++ src/backend/editor/license/license-flow.ts | 344 ++++++++++++++ .../__tests__/read-license-framing.test.ts | 120 +++++ src/backend/editor/modbus/modbus-client.ts | 64 +++ .../editor/modbus/modbus-rtu-client.ts | 186 ++++++-- .../utils/__tests__/http-module.test.ts | 38 ++ src/backend/editor/utils/http-module.ts | 46 ++ .../__tests__/fixtures/license-golden.json | 18 + .../__tests__/license-blob-c-parity.test.ts | 103 +++++ .../debug/__tests__/license-blob.test.ts | 140 ++++++ .../shared/debug/__tests__/modbus-pdu.test.ts | 176 ++++++++ .../websocket-debug-transport-license.test.ts | 137 ++++++ src/backend/shared/debug/index.ts | 17 +- src/backend/shared/debug/license-blob.ts | 147 ++++++ src/backend/shared/debug/modbus-pdu.ts | 121 +++++ src/backend/shared/debug/types.ts | 59 +++ .../shared/debug/websocket-debug-transport.ts | 44 +- .../__tests__/board-info-resolver.test.ts | 81 ++++ .../shared/hardware/board-info-resolver.ts | 37 ++ .../__tests__/modbus-rtu-client.test.ts | 129 ++++++ .../shared/simulator/modbus-rtu-client.ts | 61 ++- src/backend/shared/simulator/types.ts | 13 + .../__tests__/device-license-status.test.tsx | 185 ++++++++ .../editor/device/configuration/board.tsx | 19 + .../components/device-license-status.tsx | 226 ++++++++++ .../__tests__/use-device-connect.test.ts | 177 ++++++++ src/frontend/hooks/use-device-connect.ts | 39 +- src/frontend/hooks/use-device-license.ts | 161 +++++++ .../store/__tests__/device-slice.test.ts | 105 +++++ .../store/__tests__/device-types.test.ts | 4 + src/frontend/store/slices/device/index.ts | 1 + src/frontend/store/slices/device/slice.ts | 37 +- src/frontend/store/slices/device/types.ts | 44 ++ .../utils/__tests__/license-buy-url.test.ts | 85 ++++ .../__tests__/license-outcome-dialog.test.ts | 192 ++++++++ src/frontend/utils/license-buy-url.ts | 57 +++ src/frontend/utils/license-outcome-dialog.ts | 137 ++++++ .../__tests__/device-license.handler.test.ts | 263 +++++++++++ src/main/modules/ipc/main.ts | 112 +++++ src/main/modules/ipc/renderer.ts | 15 +- .../editor/__tests__/device-adapter.test.ts | 31 ++ .../editor/__tests__/system-adapter.test.ts | 15 + .../adapters/editor/device-adapter.ts | 18 +- .../adapters/editor/system-adapter.ts | 23 + src/middleware/shared/ports/device-port.ts | 96 ++++ src/middleware/shared/ports/system-port.ts | 15 + src/middleware/shared/ports/types.ts | 23 + .../resolve-licensing-target.test.ts | 93 ++++ .../shared/utils/licensing/index.ts | 1 + .../licensing/resolve-licensing-target.ts | 61 +++ .../utils/target-capabilities/presets.ts | 20 + .../utils/target-capabilities/resolve.ts | 4 +- .../shared/utils/target-capabilities/types.ts | 26 ++ 72 files changed, 5868 insertions(+), 44 deletions(-) create mode 100644 resources/sources/Baremetal/license_blob.h create mode 100644 resources/sources/Baremetal/license_gate.h create mode 100644 resources/sources/Baremetal/license_gate_weak.cpp create mode 100644 resources/sources/Baremetal/license_store.h create mode 100644 resources/sources/Baremetal/license_store_weak.cpp create mode 100644 src/backend/editor/license/__tests__/device-identity.test.ts create mode 100644 src/backend/editor/license/__tests__/license-activation-client.test.ts create mode 100644 src/backend/editor/license/__tests__/license-flow.test.ts create mode 100644 src/backend/editor/license/device-identity.ts create mode 100644 src/backend/editor/license/license-activation-client.ts create mode 100644 src/backend/editor/license/license-flow.ts create mode 100644 src/backend/editor/modbus/__tests__/read-license-framing.test.ts create mode 100644 src/backend/editor/utils/__tests__/http-module.test.ts create mode 100644 src/backend/editor/utils/http-module.ts create mode 100644 src/backend/shared/debug/__tests__/fixtures/license-golden.json create mode 100644 src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts create mode 100644 src/backend/shared/debug/__tests__/license-blob.test.ts create mode 100644 src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts create mode 100644 src/backend/shared/debug/license-blob.ts create mode 100644 src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/configuration/components/device-license-status.tsx create mode 100644 src/frontend/hooks/use-device-license.ts create mode 100644 src/frontend/utils/__tests__/license-buy-url.test.ts create mode 100644 src/frontend/utils/__tests__/license-outcome-dialog.test.ts create mode 100644 src/frontend/utils/license-buy-url.ts create mode 100644 src/frontend/utils/license-outcome-dialog.ts create mode 100644 src/main/modules/ipc/__tests__/device-license.handler.test.ts create mode 100644 src/middleware/shared/utils/licensing/__tests__/resolve-licensing-target.test.ts create mode 100644 src/middleware/shared/utils/licensing/index.ts create mode 100644 src/middleware/shared/utils/licensing/resolve-licensing-target.ts diff --git a/configs/webpack/webpack.config.renderer.dev.ts b/configs/webpack/webpack.config.renderer.dev.ts index 7da2db258..82d5857bb 100644 --- a/configs/webpack/webpack.config.renderer.dev.ts +++ b/configs/webpack/webpack.config.renderer.dev.ts @@ -173,6 +173,11 @@ const configuration: webpack.Configuration = { // `npm run dev` to point at staging or localhost: // `VPP_CATALOG_URL=http://localhost:3333 npm run dev` VPP_CATALOG_URL: '', + // Same mechanism for the Edge WEB app (the `/buy` license page), which is + // a DIFFERENT origin from the API above. Falls back to the production + // host hardcoded in `system-adapter.ts` when unset: + // `OPENPLC_EDGE_WEB_URL=http://localhost:5173 npm run dev` + OPENPLC_EDGE_WEB_URL: '', }), new webpack.DefinePlugin({ diff --git a/configs/webpack/webpack.config.renderer.prod.ts b/configs/webpack/webpack.config.renderer.prod.ts index 4cbf7f713..736450039 100644 --- a/configs/webpack/webpack.config.renderer.prod.ts +++ b/configs/webpack/webpack.config.renderer.prod.ts @@ -141,6 +141,10 @@ const configuration: webpack.Configuration = { // (`https://api.autonomylogic.com`) wins. Local/staging // builds can prepend `VPP_CATALOG_URL=...` to override. VPP_CATALOG_URL: '', + // Same for the Edge WEB app host (the `/buy` license page) — a different + // origin from the API above. Unset in release builds → `system-adapter.ts` + // falls back to https://edge.autonomylogic.com. + OPENPLC_EDGE_WEB_URL: '', }), new MiniCssExtractPlugin({ diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 8ff5c460c..c94141608 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -29,6 +29,8 @@ #include "openplc.h" #include "defines.h" #include "arduino_runtime_glue.h" +#include "license_gate.h" +#include "license_store.h" // license_store_read + LIC_BLOB_SIZE (via license_blob.h) #if defined(MODBUS_ENABLED) || defined(DEBUGGER_ENABLED) #include "ModbusSlave.h" @@ -134,6 +136,41 @@ void setup() // read RUN and start immediately, as they always have. runtime_init_plc_state(); + // ----------------------------------------------------------------------- + // License gate. Hand the stored license blob to the license-core so it can + // verify it and arm its demo timer. + // + // With no license-core linked, `license_gate_init()` is the weak default in + // license_gate_weak.cpp and this whole block is a harmless no-op: actuation + // then stays unconditionally allowed, i.e. a board that never had licensing + // behaves exactly as before. `millis()` gives the core the same time base the + // runtime uses, with no esp_timer dependency. + // + // THE HARDWARE ANCHOR IS NOT PASSED IN. An earlier design read `UniqueID` + // here and handed the bytes over, which made the board's IDENTITY a claim + // made by the OPEN firmware: licensing hardware you do not own cost one edit + // to this file, substituting the target's anchor. The license-core reads the + // silicon itself, inside the closed artifact, so there is nothing here to + // substitute. (FC 0x48 still REPORTS the anchor to the editor, so a purchase + // can be bound to this board — reporting an identity and asserting one are + // different things.) + // ----------------------------------------------------------------------- + { + // Zero-initialised: on a failed read the core is handed length 0, and a + // buffer of indeterminate bytes behind a zero length is the kind of detail + // that turns into a hard-to-place bug the first time someone reads past it. + uint8_t lic_blob[LIC_BLOB_SIZE] = {0}; + size_t lic_len = 0; + if (license_store_read(lic_blob, sizeof(lic_blob), &lic_len) != LIC_STORE_OK) + { + // EMPTY / CORRUPT / UNSUPPORTED / any error: nothing usable was read, + // so present a zero-length blob. The core's verify rejects it and + // starts the demo window; with no core the weak gate ignores the args. + lic_len = 0; + } + + license_gate_init(lic_blob, lic_len, (uint32_t)millis()); + } #ifdef MODBUS_ENABLED #ifdef MBSERIAL diff --git a/resources/sources/Baremetal/license_blob.h b/resources/sources/Baremetal/license_blob.h new file mode 100644 index 000000000..7c7c1694c --- /dev/null +++ b/resources/sources/Baremetal/license_blob.h @@ -0,0 +1,93 @@ +/* +license_blob.h - On-device license blob binary layout + storage CRC +Copyright (C) 2022 OpenPLC - Thiago Alves + +The C side of the license blob contract. Cross-pinned to the TypeScript +serializer (src/backend/shared/debug/license-blob.ts) by the golden vector in +its __tests__/fixtures/license-golden.json -- a layout change on either side +must fail a test rather than produce a blob the other end silently rejects. +Shared by every storage backend (AVR EEPROM, ESP32 NVS) and by the Modbus +license handlers. Layout is PACKED and LITTLE-ENDIAN for every multi-byte field +of the struct (magic, crc32). This is INDEPENDENT of the Modbus wire, which +carries the transfer `len` in BIG-ENDIAN (see modbus_pdu.cpp / modbus_debug.cpp). + + ENDIANNESS DUALITY: blob CONTENT is LITTLE-ENDIAN; the Modbus wire + `len` field is BIG-ENDIAN. Do not confuse the two. +*/ + +#ifndef LICENSE_BLOB_H +#define LICENSE_BLOB_H + +#include +#include + +// Byte layout (packed, contiguous — no padding): +// off field type size notes +// 0 magic uint32_t LE 4 'OPLC' -> bytes 4F 50 4C 43 (LE u32 = 0x434C504F) +// 4 fmt_version uint8_t 1 +// 5 key_id uint8_t 1 signing-key id (rotation) +// 6 device_id uint8_t[16] 16 +// 22 product_id uint8_t[8] 8 vpp id +// 30 (end of signed payload — payload = 30 bytes) +// 30 signature uint8_t[64] 64 ECDSA P-256 r||s raw (not DER) +// 94 crc32 uint32_t LE 4 over [payload||signature] (offsets 0..93) +// 98 (end of blob — sizeof(lic_blob_t) == 98) + +#pragma pack(push, 1) +typedef struct { + uint32_t magic; // 'OPLC' -> bytes 4F 50 4C 43 (LE u32 = 0x434C504F) + uint8_t fmt_version; + uint8_t key_id; // signing-key id (rotation) + uint8_t device_id[16]; + uint8_t product_id[8]; // vpp id +} lic_payload_t; // 30 bytes + +typedef struct __attribute__((packed)) { + lic_payload_t payload; // offsets 0..29 + uint8_t signature[64]; // offsets 30..93 (ECDSA P-256 r||s raw) + uint32_t crc32; // offset 94 (covers [payload||signature], 0..93) +} lic_blob_t; // 98 bytes +#pragma pack(pop) + +// Belt-and-suspenders: both #pragma pack and __attribute__((packed)) so AVR-GCC +// and xtensa-GCC (which treat the two differently) both drop the padding. + +#define LIC_MAGIC_LE 0x434C504Fu /* bytes 4F 50 4C 43 */ +#define LIC_BLOB_SIZE 98u +#define LIC_PAYLOAD_SIZE 30u + +// Portable compile-time assert. Every Baremetal .cpp includes this header, so it +// is compiled as C++, where static_assert is a keyword. _Static_assert is C-only +// (C11) and the C++ frontend (xtensa/avr gcc) rejects it. Keep the C form for the +// host golden test, which compiles this header as C11. +#if defined(__cplusplus) + #define LIC_STATIC_ASSERT(cond, msg) static_assert(cond, msg) +#else + #define LIC_STATIC_ASSERT(cond, msg) _Static_assert(cond, msg) +#endif + +LIC_STATIC_ASSERT(sizeof(lic_payload_t) == 30, "lic_payload_t must be 30 bytes"); +LIC_STATIC_ASSERT(sizeof(lic_blob_t) == 98, "lic_blob_t must be 98 bytes"); + +// CRC-32/ISO-HDLC (a.k.a. CRC-32, zlib/PKZIP). +// poly 0xEDB88320 (reflected) · init 0xFFFFFFFF · refin/refout true · xorout 0xFFFFFFFF +// test vector: crc32_iso_hdlc("123456789", 9) == 0xCBF43926 +// Bitwise loop (no 256-entry table) to save flash on AVR — the blob is only 94 +// bytes, so the ~8x/byte cost is negligible. Must match the TS implementation +// (license-blob.ts crc32IsoHdlc) byte-for-byte. +static inline uint32_t crc32_iso_hdlc(const uint8_t *data, size_t len) +{ + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < len; i++) + { + crc ^= (uint32_t)data[i]; + for (uint8_t b = 0; b < 8; b++) + { + uint32_t mask = -(int32_t)(crc & 1u); + crc = (crc >> 1) ^ (0xEDB88320u & mask); + } + } + return crc ^ 0xFFFFFFFFu; +} + +#endif diff --git a/resources/sources/Baremetal/license_gate.h b/resources/sources/Baremetal/license_gate.h new file mode 100644 index 000000000..e89846b5d --- /dev/null +++ b/resources/sources/Baremetal/license_gate.h @@ -0,0 +1,80 @@ +/* +license_gate.h - License enforcement gate (verify + demo window). +The closed license-core (prebuilt) provides the STRONG implementation (verify + +15-minute demo timer). The open firmware ships a weak default that reports +UNSUPPORTED and allows actuation, so boards without a license-core behave as +before. The clock is injected (now_ms) so the core stays host-testable. +*/ +#ifndef LICENSE_GATE_H +#define LICENSE_GATE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + LIC_GATE_FULL = 0, /* valid license -> full operation */ + LIC_GATE_DEMO = 1, /* no/invalid license -> demo window running */ + LIC_GATE_DEMO_EXPIRED = 2, /* demo window elapsed -> actuation must stop */ + LIC_GATE_UNSUPPORTED = 3, /* no license-core linked (weak default) -> unenforced */ +} lic_gate_state_t; + +/* 15 minutes. Overridable at build (-DLIC_GATE_DEMO_MS=...) for bench tests + * that must watch the demo expire in seconds; production keeps the default. */ +#ifndef LIC_GATE_DEMO_MS +#define LIC_GATE_DEMO_MS 900000u +#endif + +/* + * Verify the stored blob once and arm the gate. + * blob / blob_len - the 98 bytes read out of on-device storage. + * now_ms - injected clock, so the core stays host-testable. + * + * The device anchor is NOT a parameter: license_core reads it from the silicon + * inside the closed artifact. It used to be passed in from the sketch, + * which made the identity a claim the open firmware could rewrite. + */ +void license_gate_init(const uint8_t *blob, size_t blob_len, uint32_t now_ms); +lic_gate_state_t license_gate_state(uint32_t now_ms); +int license_gate_actuation_allowed(uint32_t now_ms); + +/* + * May outputs be driven RIGHT NOW? Takes no clock on purpose. + * + * This is the question a signed HAL asks at the top of its own raw output write, + * so that calling `hal_write_outputs` directly -- which open code can do, the + * symbol is declared in openplc.h -- cannot skip the gate the way it did when the + * only check lived inside updateOutputBuffers(). + * + * No now_ms parameter because the caller must not choose the time: an entry point + * that accepts a timestamp accepts the boot timestamp forever. The gate reads a + * monotonic clock inside the closed artifact instead. + * + * Returns 1 when actuation is allowed (FULL, or a demo window still running) and + * 0 once the demo has expired. Fail-open before init, like + * license_gate_actuation_allowed: the firmware always inits in setup. + */ +int license_gate_outputs_permitted(void); + +#ifndef ARDUINO +/* + * HOST-TEST SEAM, absent from every device build by construction -- `ARDUINO` is + * defined for the prebuilt `.a`, so this symbol is not in the artifact a VPP + * ships. It has to be absent: `license_gate_init` refuses a second call + * specifically so open code cannot re-arm the demo window, and an exported + * "forget you were initialised" would hand that back with a nicer name. + * + * The host tests need it because they exercise FULL, expiry and millis-wrap as + * separate scenarios against one set of file-scope statics. + */ +void license_gate_reset_for_test(void); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* LICENSE_GATE_H */ diff --git a/resources/sources/Baremetal/license_gate_weak.cpp b/resources/sources/Baremetal/license_gate_weak.cpp new file mode 100644 index 000000000..d91edc938 --- /dev/null +++ b/resources/sources/Baremetal/license_gate_weak.cpp @@ -0,0 +1,41 @@ +/* +license_gate_weak.cpp - Weak fallback for the license enforcement gate. +Copyright (C) 2022 OpenPLC - Thiago Alves + +Guarantees the firmware always links even when no platform VPP provides a real +license-core. The VPP's prebuilt license-core (.a) defines the STRONG symbols +(ECDSA verify + 15-minute demo timer), which override these weak defaults at +link time. When absent, these run: the gate reports LIC_GATE_UNSUPPORTED and +actuation stays unconditionally allowed, so a board without a license-core +behaves exactly as it did before licensing existed (no enforcement). +*/ +#include "license_gate.h" + +__attribute__((weak)) void license_gate_init(const uint8_t *blob, size_t blob_len, + uint32_t now_ms) +{ + (void)blob; + (void)blob_len; + (void)now_ms; +} + +__attribute__((weak)) lic_gate_state_t license_gate_state(uint32_t now_ms) +{ + (void)now_ms; + return LIC_GATE_UNSUPPORTED; +} + +__attribute__((weak)) int license_gate_actuation_allowed(uint32_t now_ms) +{ + (void)now_ms; + return 1; +} + +// Same unenforced answer, for the same reason: a board with no license-core is +// not a board running an expired demo. A licensable VPP overrides this with the +// strong version from its closed .a, and its HAL asks THAT one before driving a +// pin — so this default is only ever reached where there is nothing to enforce. +__attribute__((weak)) int license_gate_outputs_permitted(void) +{ + return 1; +} diff --git a/resources/sources/Baremetal/license_store.h b/resources/sources/Baremetal/license_store.h new file mode 100644 index 000000000..72e716129 --- /dev/null +++ b/resources/sources/Baremetal/license_store.h @@ -0,0 +1,64 @@ +/* +license_store.h - Single storage interface for the on-device license blob +Copyright (C) 2022 OpenPLC - Thiago Alves + +The one point of contact for persisting the license blob. The closed license-core +CONSUMES this interface; the open-source firmware IMPLEMENTS it (per-arch backend, +each self-gated on its ARDUINO_ARCH_* macro: ESP32 NVS / ESP8266 emulated-EEPROM / +AVR EEPROM). + +license_store_read validates magic + crc32 internally, so it returns semantic +status (EMPTY / CORRUPT). ECDSA verify lives ABOVE this layer (out of scope here). +*/ + +#ifndef LICENSE_STORE_H +#define LICENSE_STORE_H + +#include +#include + +#include "license_blob.h" +#include "modbus_types.h" // MB_DEBUG_* status codes for lic_status_to_mb() + +typedef enum { + LIC_STORE_OK = 0, // operation done; for read: valid blob (magic+crc match) + LIC_STORE_EMPTY = 1, // virgin storage: magic absent OR key/region never written + LIC_STORE_CORRUPT = 2, // magic present but crc32 mismatch + LIC_STORE_IO_ERROR = 3, // backend failure (NVS begin/commit; EEPROM unavailable) + LIC_STORE_TOO_LARGE = 4, // len > backend capacity (write) or > caller buffer (read) + LIC_STORE_UNSUPPORTED = 5,// no backend on this board (weak default): licensing FCs degrade gracefully +} lic_store_status_t; + +// Write `len` raw bytes. Validates len <= capacity (else TOO_LARGE). +// Does NOT validate magic/crc/signature (integrity is checked on read / at verify). +lic_store_status_t license_store_write(const uint8_t *blob, size_t len); + +// Read the blob into `out` (capacity `cap`). Writes the read size into *out_len. +// EMPTY if virgin, CORRUPT if magic ok but crc fails, TOO_LARGE if cap < stored size. +lic_store_status_t license_store_read(uint8_t *out, size_t cap, size_t *out_len); + +// Erase the license (NVS remove / EEPROM zero the length). Idempotent. +lic_store_status_t license_store_erase(void); + +// Map a storage status to the Modbus debug status byte returned on the wire. +// OK -> MB_DEBUG_SUCCESS (0x7E) +// TOO_LARGE -> MB_DEBUG_ERROR_OUT_OF_BOUNDS (0x81, reused) +// IO_ERROR -> MB_DEBUG_ERROR_OUT_OF_MEMORY (0x82, reused) +// EMPTY -> MB_DEBUG_LIC_EMPTY (0x83) +// CORRUPT -> MB_DEBUG_LIC_CORRUPT (0x84) +// UNSUPPORTED -> MB_DEBUG_LIC_UNSUPPORTED (0x85) +static inline uint8_t lic_status_to_mb(lic_store_status_t st) +{ + switch (st) + { + case LIC_STORE_OK: return MB_DEBUG_SUCCESS; + case LIC_STORE_TOO_LARGE: return MB_DEBUG_ERROR_OUT_OF_BOUNDS; + case LIC_STORE_IO_ERROR: return MB_DEBUG_ERROR_OUT_OF_MEMORY; + case LIC_STORE_EMPTY: return MB_DEBUG_LIC_EMPTY; + case LIC_STORE_CORRUPT: return MB_DEBUG_LIC_CORRUPT; + case LIC_STORE_UNSUPPORTED: return MB_DEBUG_LIC_UNSUPPORTED; + default: return MB_DEBUG_ERROR_OUT_OF_MEMORY; + } +} + +#endif diff --git a/resources/sources/Baremetal/license_store_weak.cpp b/resources/sources/Baremetal/license_store_weak.cpp new file mode 100644 index 000000000..e01aa5649 --- /dev/null +++ b/resources/sources/Baremetal/license_store_weak.cpp @@ -0,0 +1,19 @@ +/* +license_store_weak.cpp - Weak fallback backend for the license store. +Copyright (C) 2022 OpenPLC - Thiago Alves + +Guarantees the firmware always links even when no platform VPP provides a real +backend. A VPP that ships license_store_.cpp defines the STRONG symbols, +which override these weak defaults at link time. When absent, these run and +report LIC_STORE_UNSUPPORTED, so the licensing FCs degrade gracefully. +*/ +#include "license_store.h" + +__attribute__((weak)) lic_store_status_t license_store_write(const uint8_t *, size_t) +{ return LIC_STORE_UNSUPPORTED; } + +__attribute__((weak)) lic_store_status_t license_store_read(uint8_t *, size_t, size_t *out_len) +{ if (out_len) *out_len = 0; return LIC_STORE_UNSUPPORTED; } + +__attribute__((weak)) lic_store_status_t license_store_erase(void) +{ return LIC_STORE_UNSUPPORTED; } diff --git a/resources/sources/Baremetal/modbus_debug.cpp b/resources/sources/Baremetal/modbus_debug.cpp index 85a56df5a..4c8bd8ee3 100644 --- a/resources/sources/Baremetal/modbus_debug.cpp +++ b/resources/sources/Baremetal/modbus_debug.cpp @@ -1,9 +1,10 @@ /* -modbus_debug.cpp - OpenPLC always-on debugger function codes (0x41-0x48) +modbus_debug.cpp - OpenPLC always-on debugger function codes (0x41-0x4B) Copyright (C) 2022 OpenPLC - Thiago Alves */ #include "modbus_debug.h" +#include "license_store.h" // license_store_read/write + lic_status_to_mb // Debug surface comes via the extern "C" shims in arduino_runtime_glue.h // (openplc_debug_*, scan_counter) so this TU stays free of strucpp's // template-heavy headers and compiles cleanly under arduino-cli's default C++ @@ -433,3 +434,86 @@ void debugGetBoardId() mb_frame_len = 4; #endif } + +// --------------------------------------------------------------------------- +// On-device license storage (FC 0x49 write / 0x4A read) +// +// These two only MOVE BYTES. They do not verify a signature and do not decide +// anything about execution: that is the closed license-core's job, via +// license_gate.h. Keeping them dumb is what lets the open firmware carry them. +// --------------------------------------------------------------------------- + +// PDU request: [FC][len:u16 BE][blob...] (dispatcher passes `len` already unpacked) +// PDU response: [FC][STATUS] +// +// NOTE on endianness: `len` on the wire is BIG-ENDIAN (matches every other debug +// FC, e.g. GET_LIST/SET). The blob CONTENT it carries is little-endian — the two +// are independent. `blob` points at mb_frame[4], and the response is only written +// after license_store_write has consumed it, so there is no overlap hazard. +void debugWriteLicense(uint16_t len, const uint8_t *blob) +{ + // The license blob is a FIXED 98 bytes; anything else is not one, so refuse + // before the store ever sees it. Two separate problems close here. + // + // 1. CONTRACT. The two targets disagreed about this exact field: the Linux + // runtime answers LIC_CORRUPT for len != 98, while bare metal used to pass + // `len` straight through — so [0x49][len=0x0004][4 bytes] came back + // SUCCESS. One command, two contracts, and the editor believing whichever + // target it happened to be talking to. + // + // 2. OVERREAD. `len` is read from mb_frame[2..3] and can be up to 0xFFFF, + // while `blob` points into `mb_frame`, a static buffer of MAX_MB_FRAME + // (128 on 328P, 256 elsewhere). Over RTU this was already unreachable: + // mb_pdu_request_len() computes 6 + len, modbus_serial.cpp drops any frame + // whose expected length exceeds MAX_MB_FRAME, and it waits for the bytes to + // actually arrive. Over TCP nothing checked it: modbus_tcp.cpp bounds only + // the MBAP length (mb_frame_len) and never cross-checks it against this + // field, so a 6-byte packet declaring len = 0xFFFF would make + // license_store_write read ~64 KB past the frame. Testing `len` here is the + // one check that covers both transports at once. + if ((size_t)len != (size_t)LIC_BLOB_SIZE) + { + mb_frame[1] = MB_FC_DEBUG_WRITE_LICENSE; + mb_frame[2] = lic_status_to_mb(LIC_STORE_CORRUPT); + mb_frame_len = 3; + return; + } + + lic_store_status_t st = license_store_write(blob, (size_t)len); + mb_frame[1] = MB_FC_DEBUG_WRITE_LICENSE; + mb_frame[2] = lic_status_to_mb(st); + mb_frame_len = 3; +} + +// PDU request: [FC] +// PDU response (OK): [FC][STATUS][len:u16 BE][blob...] +// PDU response (EMPTY/CORRUPT/error): [FC][STATUS] (no len, no blob) +// +// Absolute mb_frame indices (index 0 is the slave id, the PDU starts at 1, +// exactly like debugGetBoardId): FC@1, STATUS@2, len@3..4 (BIG-ENDIAN), blob@5. +// +// The store reads straight into &mb_frame[5]: the frame IS the static buffer, so +// there is no malloc on AVR. READ carries no request payload, so writing at [5] +// cannot clobber an input. `out_len` is unknown until after the read, and the len +// field lives at [3..4] — BEFORE the blob — so filling it afterwards never +// overlaps the blob bytes. A 98-byte blob fits MAX_MB_FRAME comfortably. +void debugReadLicense(void) +{ + size_t out_len = 0; + lic_store_status_t st = + license_store_read(&mb_frame[5], MAX_MB_FRAME - 5, &out_len); + + mb_frame[1] = MB_FC_DEBUG_READ_LICENSE; + mb_frame[2] = lic_status_to_mb(st); + if (st == LIC_STORE_OK) + { + // len BIG-ENDIAN at [3..4] (blob content stays little-endian). + mb_frame[3] = (uint8_t)((out_len >> 8) & 0xFF); + mb_frame[4] = (uint8_t)(out_len & 0xFF); + mb_frame_len = 5 + out_len; + } + else + { + mb_frame_len = 3; // [FC][STATUS] only + } +} diff --git a/resources/sources/Baremetal/modbus_debug.h b/resources/sources/Baremetal/modbus_debug.h index a9cf7d1fc..347ce9904 100644 --- a/resources/sources/Baremetal/modbus_debug.h +++ b/resources/sources/Baremetal/modbus_debug.h @@ -1,5 +1,5 @@ /* -modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x48, 0x4B) +modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x4B) Copyright (C) 2022 OpenPLC - Thiago Alves The debugger PDU handlers, dispatched from process_mbpacket. Kept ungated: the @@ -24,6 +24,10 @@ void debugGetMd5(void *endianness); void debugGetStatus(void); void debugGetVersion(void); void debugGetBoardId(void); +// On-device license storage (0x49/0x4A). `len` is the BIG-ENDIAN wire length +// (already unpacked by the dispatcher); the blob CONTENT is little-endian. +void debugWriteLicense(uint16_t len, const uint8_t *blob); // 0x49 +void debugReadLicense(void); // 0x4A // FC 0x4B -- set the runtime run/stop state. Command only; the state is read // back through debugGetStatus (FC 0x46), which reports it. void plcSetState(uint8_t desired); diff --git a/resources/sources/Baremetal/modbus_pdu.cpp b/resources/sources/Baremetal/modbus_pdu.cpp index c3a480676..995ced8ab 100644 --- a/resources/sources/Baremetal/modbus_pdu.cpp +++ b/resources/sources/Baremetal/modbus_pdu.cpp @@ -40,7 +40,13 @@ int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n) case MB_FC_DEBUG_GET_STATUS: case MB_FC_DEBUG_GET_VERSION: case MB_FC_DEBUG_GET_BOARD_ID: + case MB_FC_DEBUG_READ_LICENSE: return 4; // [id][fc][crc:2] + case MB_FC_DEBUG_WRITE_LICENSE: + if (n < 4) return 0; // len (BE) lives at f[2..3] + // [id][fc][len:2][blob:len][crc:2] -> overhead 6 + blob len. + // NOTE: len is BIG-ENDIAN on the wire (blob content is little-endian). + return 6 + (int32_t)(((uint16_t)f[2] << 8) | f[3]); case MB_FC_PLC_SET_STATE: return 5; // [id][fc][state:1][crc:2] default: @@ -63,6 +69,8 @@ bool mb_pdu_skips_crc(uint8_t fc) case MB_FC_DEBUG_GET_STATUS: case MB_FC_DEBUG_GET_VERSION: case MB_FC_DEBUG_GET_BOARD_ID: + case MB_FC_DEBUG_WRITE_LICENSE: + case MB_FC_DEBUG_READ_LICENSE: return true; default: return false; @@ -178,6 +186,20 @@ void process_mbpacket() debugGetBoardId(); break; + case MB_FC_DEBUG_WRITE_LICENSE: + { + // PDU: [FC:1][len:u16 BE][blob...] + // len is BIG-ENDIAN (same convention as GET_LIST/SET); the blob + // CONTENT it carries is little-endian. + uint16_t lic_len = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; + debugWriteLicense(lic_len, &mb_frame[4]); + } + break; + + case MB_FC_DEBUG_READ_LICENSE: + debugReadLicense(); + break; + case MB_FC_PLC_SET_STATE: // PDU: [FC:1][state:u8] (0 = STOP, 1 = RUN) plcSetState(mb_frame[2]); diff --git a/resources/sources/Baremetal/modbus_types.h b/resources/sources/Baremetal/modbus_types.h index 6eb6ebd5f..be8fb1d5a 100644 --- a/resources/sources/Baremetal/modbus_types.h +++ b/resources/sources/Baremetal/modbus_types.h @@ -41,6 +41,15 @@ protocol, transport, register and debug layers agree on the same contracts. #define MB_DEBUG_SUCCESS 0x7E #define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 #define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 +// License storage semantic states. The editor distinguishes all three: EMPTY and +// CORRUPT both mean "recover from the backend", while UNSUPPORTED means the board +// cannot hold a license at all and buying one would not help. They don't collide +// with Modbus exceptions (0x01-0x04) nor 0x7E/0x81/0x82. +#define MB_DEBUG_LIC_EMPTY 0x83 +#define MB_DEBUG_LIC_CORRUPT 0x84 +// No license-store backend on this board (weak default): the licensing FCs +// degrade gracefully instead of failing as a transport error. +#define MB_DEBUG_LIC_UNSUPPORTED 0x85 // MB_FC_PLC_SET_STATE only: a RUN request was refused because the hardware mode // switch reads STOP. The editor turns this into a "flip the switch to RUN" // warning rather than a generic failure. It doesn't collide with Modbus @@ -82,6 +91,8 @@ enum { MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID + MB_FC_DEBUG_WRITE_LICENSE = 0x49, // Debug write license blob to on-device storage + MB_FC_DEBUG_READ_LICENSE = 0x4A, // Debug read license blob from on-device storage MB_FC_PLC_SET_STATE = 0x4B, // Set the runtime run/stop state }; diff --git a/resources/sources/arduino/openplc.h b/resources/sources/arduino/openplc.h index eafb41d39..ddfc8d7e4 100644 --- a/resources/sources/arduino/openplc.h +++ b/resources/sources/arduino/openplc.h @@ -122,6 +122,31 @@ uint8_t hardwareStateSwitch(void); * HAL with no LED reads nothing and the runtime never knows the difference. * ---------------------------------------------------------------------- */ uint8_t runtime_get_plc_state(void); + +/* ---- Raw I/O ops, for LICENSABLE VPP targets only ---------------------- + * Two mutually exclusive shapes exist, and there is deliberately NO weak + * fallback for the gated wrappers above: + * + * - Licensable VPP HAL: defines ONLY these raw ops. The gated + * updateInputBuffers / updateOutputBuffers come from inside the closed + * license-core .a, which asks license_gate_actuation_allowed() and then + * calls hal_read_inputs / hal_write_outputs — or hal_disable_all_outputs + * once the demo window has expired. + * + * - Every other HAL (the bundled resources/sources/hal/*.cpp, and any + * unlicensed VPP): defines updateInput/OutputBuffers itself and leaves + * these raw ops undeclared-but-unused. No gate is involved. + * + * THE CONSEQUENCE IS THE POINT: dropping the license-core .a from a licensable + * VPP does not silently degrade to unenforced I/O — the firmware fails to LINK, + * with undefined updateInputBuffers / updateOutputBuffers. The only weak + * defaults shipped are license_gate_weak.cpp (gate query -> UNSUPPORTED, + * actuation allowed) and license_store_weak.cpp (blob store -> UNSUPPORTED), + * which keep NON-licensable boards linking; neither provides these wrappers. + * ---------------------------------------------------------------------- */ +void hal_read_inputs(void); +void hal_write_outputs(void); +void hal_disable_all_outputs(void); #ifdef __cplusplus } #endif diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 0aaf17a3e..68077b2a5 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2566,6 +2566,60 @@ class CompilerModule { strucppRuntimeHeaders: v4Layout, boardHalContent, }) + + // VPP-provided license-store backend source(s). Mirrors the HAL read + // above: each path is an absolute one `BoardInfoResolver` produced from + // `device.hal.licenseStore` through the same traversal-guarded + // `resolvePackageRelativePath`. + // + // Unlike the HAL (which lands at the canonical `src/arduino.cpp`), these + // keep their distinctive basenames and land in the SKETCH directory next + // to `license_store.h` / `license_blob.h`, so their + // `#include "license_store.h"` resolves and they define the STRONG + // `license_store_*` symbols that override the skeleton's + // `license_store_weak.cpp`. Boards without a VPP backend inject nothing + // and link the weak default, which answers LIC_UNSUPPORTED. + // + // A read failure is a WARNING, not a hard stop, and the asymmetry with the + // HAL check above is deliberate: a missing HAL means no I/O at all, while a + // missing store backend means the board answers "cannot store a licence" — + // degraded, correctly reported to the editor, and still a working PLC. + for (const licenseStoreFile of boardInfo.licenseStoreFiles ?? []) { + try { + const content = await readFile(licenseStoreFile, 'utf-8') + firmwareSkeleton[`examples/Baremetal/${path.basename(licenseStoreFile)}`] = content + // Logged on SUCCESS, not only on failure. Without this line the build + // output is identical whether the backend went in or not, and the only + // symptom of it missing appears much later and somewhere else: the + // board answers LIC_UNSUPPORTED and the editor says "this device + // cannot store a licence" — which reads as a hardware limitation + // rather than a firmware that was built without the backend. + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `License store backend included: ${path.basename(licenseStoreFile)}`, + }) + } catch (lsErr) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: + `Could not read license-store backend at ${licenseStoreFile}: ${getErrorMessage(lsErr)}. ` + + 'This board will report that it cannot store a licence.', + }) + } + } + // A licensable board that resolves NO backend is a manifest fault: every + // licensable VPP targets hardware that persists a licence, so the storage + // source is not optional for one. Saying it here is far cheaper than + // deducing it from a badge three steps later. + if (boardInfo.capabilities?.isLicensable === true && (boardInfo.licenseStoreFiles ?? []).length === 0) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: + `Board "${boardTarget}" belongs to a licensed VPP but its manifest declares no ` + + '`hal.licenseStore`. Every licensed VPP needs one, so this is a packaging fault: the ' + + 'firmware will link the weak default and report that its licence storage is missing.', + }) + } } try { // `devices/pin-mapping.json` ships in one of two shapes (the diff --git a/src/backend/editor/library-manager/desktop-catalog-transport.ts b/src/backend/editor/library-manager/desktop-catalog-transport.ts index 2b66ce6f7..9b941ac14 100644 --- a/src/backend/editor/library-manager/desktop-catalog-transport.ts +++ b/src/backend/editor/library-manager/desktop-catalog-transport.ts @@ -20,6 +20,7 @@ import https from 'https' import type { CatalogQueryParams, CatalogTransportPort } from '../../../middleware/shared/ports/catalog-transport-port' +import { defaultPortFor, httpModuleFor } from '../utils/http-module' /** Default base URL when no env override is set. */ const DEFAULT_EDGE_API_URL = 'https://api.autonomylogic.com' @@ -77,7 +78,7 @@ function requestText(url: string, signal?: AbortSignal): Promise { const parsed = new URL(url) const reqOptions: https.RequestOptions = { hostname: parsed.hostname, - port: parsed.port || undefined, + port: parsed.port || defaultPortFor(parsed), path: parsed.pathname + parsed.search, method: 'GET', headers: { @@ -86,7 +87,8 @@ function requestText(url: string, signal?: AbortSignal): Promise { }, } - const req = https.request(reqOptions, (res) => { + // Scheme-driven, so OPENPLC_EDGE_API_URL can point at a local http backend. + const req = httpModuleFor(parsed).request(reqOptions, (res) => { // Accumulate as a single utf-8 string instead of buffering raw // chunks — sidesteps the Buffer[] vs Uint8Array[] friction in // recent @types/node and matches the existing httpRequest in diff --git a/src/backend/editor/license/__tests__/device-identity.test.ts b/src/backend/editor/license/__tests__/device-identity.test.ts new file mode 100644 index 000000000..085cdb3b5 --- /dev/null +++ b/src/backend/editor/license/__tests__/device-identity.test.ts @@ -0,0 +1,34 @@ +import { deriveDeviceId, deriveVppId } from '../device-identity' + +describe('deriveDeviceId', () => { + it('derives the golden 16-byte device id for the real NodeMCU anchor', () => { + // Anchor 00 b1 8c ed is the real NodeMCU hardware anchor. The golden + // value below is the deterministic sha256("openplc-dev-v1|"||anchor)[:16]. + const anchor = Uint8Array.from([0, 177, 140, 237]) + expect(deriveDeviceId(anchor)).toBe('659a3520540f803625ddc34081e893d3') + }) + + it('returns 32 lowercase hex chars (16 bytes)', () => { + const id = deriveDeviceId(Uint8Array.from([1, 2, 3])) + expect(id).toMatch(/^[0-9a-f]{32}$/) + }) + + it('is deterministic and domain-separated by the prefix', () => { + const anchor = Uint8Array.from([0, 177, 140, 237]) + // Same anchor -> same id. + expect(deriveDeviceId(anchor)).toBe(deriveDeviceId(Uint8Array.from([0, 177, 140, 237]))) + // A different anchor -> different id. + expect(deriveDeviceId(anchor)).not.toBe(deriveDeviceId(Uint8Array.from([0, 177, 140, 238]))) + }) +}) + +describe('deriveVppId', () => { + it('derives the golden 8-byte vpp id for the espressif package', () => { + // Golden value = sha256("com.openplc.espressif")[:8], hex. + expect(deriveVppId('com.openplc.espressif')).toBe('29a17c7c2486d355') + }) + + it('returns 16 lowercase hex chars (8 bytes)', () => { + expect(deriveVppId('com.openplc.espressif')).toMatch(/^[0-9a-f]{16}$/) + }) +}) diff --git a/src/backend/editor/license/__tests__/license-activation-client.test.ts b/src/backend/editor/license/__tests__/license-activation-client.test.ts new file mode 100644 index 000000000..77c4087f3 --- /dev/null +++ b/src/backend/editor/license/__tests__/license-activation-client.test.ts @@ -0,0 +1,262 @@ +import { EventEmitter } from 'events' +import http from 'http' +import https from 'https' + +import { checkDeviceActivation } from '../license-activation-client' + +/** + * Fake `request`: invokes the response callback synchronously and emits the given + * status/body when `req.end()` is called, so `postJson`'s promise resolves without + * a real socket. Returns the `req` mock so tests can assert on the body written. + */ +function mockResponse(statusCode: number, jsonBody: unknown, mod: typeof https | typeof http = https) { + const req = Object.assign(new EventEmitter(), { write: jest.fn(), end: jest.fn(), setTimeout: jest.fn() }) + jest.spyOn(mod, 'request').mockImplementation(((_options: unknown, callback: (res: unknown) => void) => { + const res = Object.assign(new EventEmitter(), { statusCode, statusMessage: 'OK', setEncoding: jest.fn() }) + callback(res) + req.end.mockImplementation(() => { + res.emit('data', JSON.stringify(jsonBody)) + res.emit('end') + }) + return req as unknown as ReturnType + }) as typeof https.request) + return req +} + +const INPUT = { + deviceId: '659a3520540f803625ddc34081e893d3', + packageId: 'com.openplc.espressif-licensed', +} + +/** The golden 98-byte blob, hex — same vector as `license-golden.json`. */ +const GOLDEN_HEX = + '4f504c430100000102030405060708090a0b0c0d0e0fa0a1a2a3a4a5a6a711111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111b6311445' + +const GOLDEN_B64 = Buffer.from(GOLDEN_HEX, 'hex').toString('base64') + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('checkDeviceActivation', () => { + it('sends only { deviceId, packageId } and returns the decoded 98-byte blob', async () => { + const req = mockResponse(200, { + statusCode: 200, + data: { licensed: true, deviceId: INPUT.deviceId, vppId: INPUT.packageId, license: GOLDEN_B64 }, + }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(true) + expect(result.license).toHaveLength(98) + expect(Buffer.from(result.license ?? []).toString('hex')).toBe(GOLDEN_HEX) + + // Whole-key equality, not a per-field check: an extra field creeping back in + // (a nonce, a signature, a client-declared hwClass) has to fail here. + const sent = JSON.parse((req.write as jest.Mock).mock.calls[0][0] as string) as Record + expect(Object.keys(sent).sort()).toEqual(['deviceId', 'packageId']) + }) + + it('passes through licensed:false with a reason (no purchase on record)', async () => { + mockResponse(200, { statusCode: 200, data: { licensed: false, reason: 'no active subscription' } }) + + const result = await checkDeviceActivation(INPUT) + + // `reason` present and `error` absent is what tells the caller demo mode is + // the CORRECT outcome, rather than a failure to reach the backend. + expect(result.licensed).toBe(false) + expect(result.reason).toBe('no active subscription') + expect(result.error).toBeUndefined() + expect(result.license).toBeUndefined() + }) + + it('reports a non-2xx as an error, not as "no purchase"', async () => { + // A 404 (unknown package), 429 (rate limited) or 503 (no signer) must NOT + // read as "this device has no license" — that is how someone who already paid + // gets told to buy again. + mockResponse(404, { statusCode: 404, data: { message: 'Unknown VPP' } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/404/) + expect(result.reason).toBeUndefined() + }) + + it('reports a transport failure as an error rather than throwing', async () => { + jest.spyOn(https, 'request').mockImplementation(((_options: unknown, _callback: unknown) => { + const emitter = new EventEmitter() + const req = Object.assign(emitter, { + write: jest.fn(), + end: jest.fn(() => { + emitter.emit('error', new Error('ECONNREFUSED')) + }), + setTimeout: jest.fn(), + }) + return req as unknown as ReturnType + }) as typeof https.request) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/ECONNREFUSED/) + }) + + it('rejects a response whose shape is off-contract', async () => { + mockResponse(200, { statusCode: 200, data: { somethingElse: true } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/Unexpected activation response shape/) + }) + + it('rejects licensed:true with no license field', async () => { + mockResponse(200, { statusCode: 200, data: { licensed: true } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/missing license blob/) + }) + + it('rejects a blob that does not decode to exactly 98 bytes', async () => { + // `Buffer.from(s, 'base64')` never throws — it skips invalid characters and + // tolerates missing padding — so without an explicit length check a truncated + // field reaches the device as a short blob and comes back as a LIC_CORRUPT + // rejection that blames the hardware. + const truncated = Buffer.from(GOLDEN_HEX, 'hex').subarray(0, 40).toString('base64') + mockResponse(200, { statusCode: 200, data: { licensed: true, license: truncated } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/40 bytes, expected 98/) + expect(result.license).toBeUndefined() + }) + + it('rejects a license field that is not base64 at all', async () => { + mockResponse(200, { statusCode: 200, data: { licensed: true, license: '!!!not base64!!!' } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/expected 98/) + }) + + it('accepts a response that is not wrapped in the { statusCode, data } envelope', async () => { + mockResponse(200, { licensed: true, license: GOLDEN_B64 }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(true) + expect(result.license).toHaveLength(98) + }) +}) + +/** + * There is NO dev license-minting path in this module. + * + * The removed mock (`OPLC_LICENSE_MOCK` / `OPLC_LICENSE_MOCK_KEY`) signed real, + * device-bound blobs a board would accept as FULL. It was compiled out of + * production builds, but it still shipped in dev builds and was one key leak away + * from unlimited offline licenses outside purchase and outside revocation. + * + * These tests assert the ABSENCE. A happy-path test would pass just as well with + * the mock silently reintroduced. + */ +describe('checkDeviceActivation (no dev mock)', () => { + const originalMock = process.env.OPLC_LICENSE_MOCK + const originalKey = process.env.OPLC_LICENSE_MOCK_KEY + + afterEach(() => { + if (originalMock === undefined) delete process.env.OPLC_LICENSE_MOCK + else process.env.OPLC_LICENSE_MOCK = originalMock + if (originalKey === undefined) delete process.env.OPLC_LICENSE_MOCK_KEY + else process.env.OPLC_LICENSE_MOCK_KEY = originalKey + }) + + it('still calls the backend, and honours its answer, with OPLC_LICENSE_MOCK=licensed set', async () => { + process.env.OPLC_LICENSE_MOCK = 'licensed' + mockResponse(200, { statusCode: 200, data: { licensed: false } }) + + const result = await checkDeviceActivation(INPUT) + + // If a mock were reachable this would be `true` with a minted blob. + expect(https.request).toHaveBeenCalled() + expect(result.licensed).toBe(false) + expect(result.license).toBeUndefined() + }) + + it('still calls the backend with OPLC_LICENSE_MOCK=demo set', async () => { + process.env.OPLC_LICENSE_MOCK = 'demo' + mockResponse(200, { statusCode: 200, data: { licensed: true, license: GOLDEN_B64 } }) + + const result = await checkDeviceActivation(INPUT) + + // A `demo` short-circuit would have returned licensed:false without a request. + expect(https.request).toHaveBeenCalled() + expect(result.licensed).toBe(true) + }) +}) + +/** + * NO proof of possession. + * + * The activate request used to be preceded by `POST /vpp-licenses/challenge` and + * to carry `nonce` + `signature` signed by a keypair derived from the hardware + * anchor. All of it is gone: on bare metal the anchor is read inside the closed + * license-core and the blob is bound to `deviceId`, so the silicon is what proves + * possession. This asserts the absence. + */ +describe('checkDeviceActivation (no proof of possession)', () => { + it('makes exactly one request, and it is the activate', async () => { + const hits: string[] = [] + jest.spyOn(https, 'request').mockImplementation(((options: unknown, callback: (res: unknown) => void) => { + hits.push(options && typeof options === 'object' && 'path' in options ? String(options.path) : '') + const req = Object.assign(new EventEmitter(), { write: jest.fn(), end: jest.fn(), setTimeout: jest.fn() }) + const res = Object.assign(new EventEmitter(), { statusCode: 200, statusMessage: 'OK', setEncoding: jest.fn() }) + callback(res) + req.end.mockImplementation(() => { + res.emit('data', JSON.stringify({ statusCode: 200, data: { licensed: true, license: GOLDEN_B64 } })) + res.emit('end') + }) + return req as unknown as ReturnType + }) as typeof https.request) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(true) + // A reintroduced challenge round-trip would show up as a second hit even if + // the flow still succeeded. + expect(hits).toEqual(['/vpp-licenses/activate']) + }) +}) + +/** + * Scheme-driven module selection is what makes local end-to-end testing possible: + * `OPENPLC_EDGE_API_URL=http://localhost:3333` must open a PLAIN socket. Sending a + * TLS ClientHello to it dies with EPROTO, the catch turns that into a generic + * failure, and the editor silently falls back to demo — so the one contract most + * worth exercising locally could not be exercised at all. + */ +describe('checkDeviceActivation (base URL scheme)', () => { + const original = process.env.OPENPLC_EDGE_API_URL + + afterEach(() => { + if (original === undefined) delete process.env.OPENPLC_EDGE_API_URL + else process.env.OPENPLC_EDGE_API_URL = original + }) + + it('uses node:http (not https) for an http base URL', async () => { + process.env.OPENPLC_EDGE_API_URL = 'http://localhost:3333' + const httpsSpy = jest.spyOn(https, 'request') + mockResponse(200, { statusCode: 200, data: { licensed: true, license: GOLDEN_B64 } }, http) + + const result = await checkDeviceActivation(INPUT) + + expect(http.request).toHaveBeenCalled() + expect(httpsSpy).not.toHaveBeenCalled() + expect(result.licensed).toBe(true) + }) +}) diff --git a/src/backend/editor/license/__tests__/license-flow.test.ts b/src/backend/editor/license/__tests__/license-flow.test.ts new file mode 100644 index 000000000..e37ad771b --- /dev/null +++ b/src/backend/editor/license/__tests__/license-flow.test.ts @@ -0,0 +1,425 @@ +import { serializeLicenseBlob } from '../../../shared/debug/license-blob' +import type { DebugLicenseReadResult, DebugLicenseWriteResult } from '../../../shared/debug/types' +import { deriveDeviceId, deriveVppId } from '../device-identity' +import { + inspectDeviceLicense, + type LicenseReadWritable, + resolveDeviceLicense, + verifyStoredLicenseBlob, +} from '../license-flow' + +jest.mock('../license-activation-client', () => ({ + checkDeviceActivation: jest.fn(), +})) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { checkDeviceActivation } = require('../license-activation-client') as { + checkDeviceActivation: jest.Mock +} + +const LIC_SUCCESS = 0x7e +const LIC_EMPTY = 0x83 +const LIC_UNSUPPORTED = 0x85 + +/** The real NodeMCU hardware anchor, and the ids that derive from it. */ +const ANCHOR = Uint8Array.from([0, 177, 140, 237]) +const PACKAGE_ID = 'com.openplc.espressif-licensed' +const DEVICE_ID = deriveDeviceId(ANCHOR) + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + return out +} + +/** A well-formed 98-byte blob for the given device + package. */ +function blobFor({ deviceId = DEVICE_ID, packageId = PACKAGE_ID }: { deviceId?: string; packageId?: string } = {}) { + return serializeLicenseBlob({ + magic: 0, // forced to the canonical magic by the serializer + fmtVersion: 1, + keyId: 0, + deviceId: hexToBytes(deviceId), + productId: hexToBytes(deriveVppId(packageId)), + signature: new Uint8Array(64).fill(7), + crc32: 0, // recomputed by the serializer + }) +} + +/** A scripted transport: successive readLicense() calls pop the queue. */ +function transport(script: { + reads: DebugLicenseReadResult[] + write?: DebugLicenseWriteResult +}): LicenseReadWritable & { writes: Uint8Array[]; readCount: () => number } { + const reads = [...script.reads] + const writes: Uint8Array[] = [] + let readCount = 0 + return { + writes, + readCount: () => readCount, + readLicense: () => { + readCount++ + const next = reads.shift() + if (!next) throw new Error('test transport: unexpected extra readLicense()') + return Promise.resolve(next) + }, + writeLicense: (blob: Uint8Array) => { + writes.push(blob) + return Promise.resolve(script.write ?? { success: true, status: LIC_SUCCESS }) + }, + } +} + +beforeEach(() => { + checkDeviceActivation.mockReset() + jest.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +// --------------------------------------------------------------------------- +// verifyStoredLicenseBlob +// --------------------------------------------------------------------------- + +describe('verifyStoredLicenseBlob', () => { + it('accepts a blob bound to this device and this package', () => { + expect(verifyStoredLicenseBlob(blobFor(), DEVICE_ID, PACKAGE_ID)).toEqual({ ok: true }) + }) + + it('rejects a blob bound to ANOTHER device (the clone case)', () => { + // Valid bytes, wrong board. The device answers 0x7E for it and the closed + // gate answers DEVICE_MISMATCH — believing the status byte here is what makes + // the badge say "Licensed" on a board running demo. + const other = deriveDeviceId(Uint8Array.from([9, 9, 9, 9])) + const verdict = verifyStoredLicenseBlob(blobFor({ deviceId: other }), DEVICE_ID, PACKAGE_ID) + + expect(verdict).toEqual({ ok: false, reason: `stored license is bound to device ${other}, not ${DEVICE_ID}` }) + }) + + it('rejects a blob issued for another VPP', () => { + const verdict = verifyStoredLicenseBlob(blobFor({ packageId: 'com.openplc.other-licensed' }), DEVICE_ID, PACKAGE_ID) + + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toMatch(/stored license is for product/) + }) + + it('rejects a blob whose crc32 does not cover its bytes (tampered or truncated in place)', () => { + const tampered = blobFor() + tampered[40] ^= 0xff // flip a signature byte; the stored crc32 no longer matches + + const verdict = verifyStoredLicenseBlob(tampered, DEVICE_ID, PACKAGE_ID) + + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toMatch(/crc32/) + }) + + it('rejects a blob with no OPLC magic', () => { + const noMagic = blobFor() + noMagic[0] = 0x00 + + const verdict = verifyStoredLicenseBlob(noMagic, DEVICE_ID, PACKAGE_ID) + + expect(verdict.ok).toBe(false) + // Magic is checked before crc32, so the message names the real problem. + if (!verdict.ok) expect(verdict.reason).toMatch(/no OPLC magic/) + }) + + it('rejects a short or absent blob rather than parsing past the end', () => { + expect(verifyStoredLicenseBlob(undefined, DEVICE_ID, PACKAGE_ID)).toEqual({ + ok: false, + reason: 'stored license is 0 bytes, expected 98', + }) + expect(verifyStoredLicenseBlob(blobFor().subarray(0, 40), DEVICE_ID, PACKAGE_ID)).toEqual({ + ok: false, + reason: 'stored license is 40 bytes, expected 98', + }) + }) + + it('leaves productId unverified when no package id is known, rather than assuming it good', () => { + const foreign = blobFor({ packageId: 'com.openplc.other-licensed' }) + + expect(verifyStoredLicenseBlob(foreign, DEVICE_ID, undefined)).toEqual({ ok: true }) + }) +}) + +// --------------------------------------------------------------------------- +// resolveDeviceLicense +// --------------------------------------------------------------------------- + +describe('resolveDeviceLicense', () => { + it('reports an already-stored license without asking the backend', async () => { + const link = transport({ reads: [{ success: true, status: LIC_SUCCESS, blob: blobFor() }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }) + expect(checkDeviceActivation).not.toHaveBeenCalled() + expect(link.writes).toHaveLength(0) + }) + + it('recovers from the backend when storage is empty, then re-reads to confirm', async () => { + const blob = blobFor() + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(blob) }) + const link = transport({ + reads: [ + { success: true, status: LIC_EMPTY, empty: true }, + { success: true, status: LIC_SUCCESS, blob }, + ], + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'activated' } }) + expect(checkDeviceActivation).toHaveBeenCalledWith({ deviceId: DEVICE_ID, packageId: PACKAGE_ID }) + expect(Array.from(link.writes[0])).toEqual(Array.from(blob)) + // Two reads: the initial probe and the mandatory read-back. + expect(link.readCount()).toBe(2) + }) + + it('FALLS THROUGH to recovery when the device reports a license that does not verify', async () => { + // 0x7E with a blob for another board. This is the case recovery exists for: + // believing the status byte would skip the only automatic repair path and + // leave the board in demo with a "Licensed" badge. + const foreign = blobFor({ deviceId: deriveDeviceId(Uint8Array.from([1, 2, 3, 4])) }) + const good = blobFor() + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(good) }) + const link = transport({ + reads: [ + { success: true, status: LIC_SUCCESS, blob: foreign }, + { success: true, status: LIC_SUCCESS, blob: good }, + ], + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(checkDeviceActivation).toHaveBeenCalled() + expect(result.outcome).toEqual({ state: 'licensed', how: 'activated' }) + }) + + it('reports unlicensed (demo + buy) when the backend says there is no purchase', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: false, reason: 'no active subscription' }) + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + // `entitlementChecked: true` is what earns the UI the right to offer a + // purchase: the backend WAS asked and said no. + expect(result.outcome).toEqual({ + state: 'unlicensed', + entitlementChecked: true, + backendReason: 'no active subscription', + }) + expect(link.writes).toHaveLength(0) + }) + + it('reports check-failed — NOT unlicensed — when the backend could not be reached', async () => { + // The distinction that matters most in this module: collapsing a transport + // failure into "no purchase" tells someone who already paid to buy again. + checkDeviceActivation.mockResolvedValue({ licensed: false, error: 'Activation request failed: 429' }) + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'Activation request failed: 429' }) + }) + + it('reports unsupported when the device has no storage backend', async () => { + const link = transport({ reads: [{ success: true, status: LIC_UNSUPPORTED, unsupported: true }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'unsupported' } }) + expect(checkDeviceActivation).not.toHaveBeenCalled() + }) + + it('never derives an id from an empty anchor', async () => { + // sha256(prefix || ) is a CONSTANT: every board without a unique id + // would share one device id, so one purchase would license the whole fleet. + const link = transport({ reads: [] }) + + const result = await resolveDeviceLicense(link, { anchor: new Uint8Array(0), packageId: PACKAGE_ID }) + + expect(result.deviceId).toBeUndefined() + expect(result.outcome.state).toBe('check-failed') + if (result.outcome.state === 'check-failed') { + expect(result.outcome.error).toMatch(/no unique hardware id/) + } + expect(link.readCount()).toBe(0) + expect(checkDeviceActivation).not.toHaveBeenCalled() + }) + + it('reports check-failed when the device does not answer the read at all', async () => { + const link = transport({ reads: [{ success: false, error: 'Request timeout' }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'check-failed', error: 'Request timeout' } }) + }) + + it('does not assert possession when the write succeeds but the read-back does not verify', async () => { + // 0x49 only stores bytes — no target validates them on write. A blob truncated + // in flight would otherwise read as "Licensed" while the board runs demo. + const good = blobFor() + const truncated = blobFor().subarray(0, 60) + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(good) }) + const link = transport({ + reads: [ + { success: true, status: LIC_EMPTY, empty: true }, + { success: true, status: LIC_SUCCESS, blob: truncated }, + ], + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome.state).toBe('check-failed') + if (result.outcome.state === 'check-failed') { + expect(result.outcome.error).toMatch(/written but could not be confirmed on the device/) + } + }) + + it('does not assert possession when the read-back itself fails', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(blobFor()) }) + const link = transport({ + reads: [ + { success: true, status: LIC_EMPTY, empty: true }, + { success: false, error: 'Request timeout' }, + ], + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome.state).toBe('check-failed') + if (result.outcome.state === 'check-failed') { + expect(result.outcome.error).toMatch(/the read-back failed/) + } + }) + + it('reports unsupported when the WRITE is the thing that reveals no storage backend', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(blobFor()) }) + const link = transport({ + reads: [{ success: true, status: LIC_EMPTY, empty: true }], + write: { success: true, status: LIC_UNSUPPORTED, unsupported: true }, + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'unsupported' }) + }) + + it('reports check-failed when the write is refused', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(blobFor()) }) + const link = transport({ + reads: [{ success: true, status: LIC_EMPTY, empty: true }], + write: { success: false, error: 'ERROR_OUT_OF_MEMORY' }, + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'ERROR_OUT_OF_MEMORY' }) + }) + + it('reports check-failed when the backend claims licensed but returns no blob', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: true }) + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome.state).toBe('check-failed') + if (result.outcome.state === 'check-failed') { + expect(result.outcome.error).toMatch(/no blob to write/) + } + expect(link.writes).toHaveLength(0) + }) + + it('turns an unexpected throw from the transport into check-failed, never a rejection', async () => { + const link: LicenseReadWritable = { + readLicense: () => Promise.reject(new Error('port closed')), + writeLicense: () => Promise.resolve({ success: true }), + } + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'check-failed', error: 'port closed' } }) + }) +}) + +// --------------------------------------------------------------------------- +// inspectDeviceLicense — read + verify, never the network +// --------------------------------------------------------------------------- + +describe('inspectDeviceLicense', () => { + it('confirms a stored, verified license', async () => { + const link = transport({ reads: [{ success: true, status: LIC_SUCCESS, blob: blobFor() }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }) + }) + + it('NEVER contacts the backend, even when the device holds nothing', async () => { + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + // The whole point of this entry point: cheap enough for a screen open. + expect(checkDeviceActivation).not.toHaveBeenCalled() + expect(link.writes).toHaveLength(0) + }) + + it('reports unlicensed with entitlementChecked:false, so the UI offers a refresh and not a purchase', async () => { + // Nobody has asked whether a purchase exists. Rendering this as "buy a + // license" would be a guess, and the wrong one for anyone who already paid. + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'unlicensed', entitlementChecked: false }) + }) + + it('treats a stored blob that fails verification as not licensed, not as licensed', async () => { + const foreign = blobFor({ deviceId: deriveDeviceId(Uint8Array.from([1, 2, 3, 4])) }) + const link = transport({ reads: [{ success: true, status: LIC_SUCCESS, blob: foreign }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'unlicensed', entitlementChecked: false }) + }) + + it('reports unsupported when the device has no storage backend', async () => { + const link = transport({ reads: [{ success: true, status: LIC_UNSUPPORTED, unsupported: true }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'unsupported' }) + }) + + it('reports check-failed when the device does not answer', async () => { + const link = transport({ reads: [{ success: false, error: 'Request timeout' }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'Request timeout' }) + }) + + it('refuses an empty anchor without reading anything', async () => { + const link = transport({ reads: [] }) + + const result = await inspectDeviceLicense(link, { anchor: new Uint8Array(0), packageId: PACKAGE_ID }) + + expect(result.deviceId).toBeUndefined() + expect(result.outcome.state).toBe('check-failed') + expect(link.readCount()).toBe(0) + }) + + it('turns an unexpected throw into check-failed', async () => { + const link: LicenseReadWritable = { + readLicense: () => Promise.reject(new Error('port closed')), + writeLicense: () => Promise.resolve({ success: true }), + } + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'check-failed', error: 'port closed' } }) + }) +}) diff --git a/src/backend/editor/license/device-identity.ts b/src/backend/editor/license/device-identity.ts new file mode 100644 index 000000000..4572e1bd8 --- /dev/null +++ b/src/backend/editor/license/device-identity.ts @@ -0,0 +1,72 @@ +/** + * License-identity derivation helpers. + * + * Both identifiers are deterministic SHA-256 digests, truncated to a + * fixed prefix and hex-encoded. They run in the Electron **main** + * process during the licensing routine, so they use `node:crypto` + * directly. + * + * This module lives under `backend/editor` (not the byte-identical + * `backend/shared` surface): `backend/shared` must compile identically + * for both openplc-editor and openplc-web and therefore cannot depend + * on `node:crypto`. The derivation only ever runs main-side, so an + * editor-only home is the correct boundary. + * + * CROSS-REPO CONTRACT. `deriveDeviceId` output is the PRIMARY KEY the + * autonomy-edge backend stores a purchase and a license against, and it + * accepts only LOWERCASE hex (`canonical-device-id.ts` there). `digest('hex')` + * is already lowercase, so this holds by construction — but nothing downstream + * may upper-case it: a purchase recorded as `7146…E5` and an activation for + * `7146…e5` become two devices, the customer pays, the seat check finds + * nothing, and the board runs demo with no error saying why. + */ + +import { createHash } from 'node:crypto' + +/** Domain-separation prefix for the device-id digest. Mixed + * in as raw ASCII bytes ahead of the hardware anchor so the same anchor + * can never collide with any other `sha256`-derived identifier. */ +const DEVICE_ID_PREFIX = 'openplc-dev-v1|' + +/** Device id = first 16 bytes of the digest (matches `lic_blob_t.deviceId`). */ +const DEVICE_ID_BYTES = 16 + +/** VPP id = first 8 bytes of the digest (matches `lic_blob_t.productId`). */ +const VPP_ID_BYTES = 8 + +/** + * Derive the 16-byte device identifier from a hardware anchor. + * + * `deviceId = sha256("openplc-dev-v1|" || anchor)[:16]`, hex-encoded + * (32 lowercase hex chars). The prefix is concatenated as ASCII bytes + * directly in front of the anchor bytes in a single buffer, so the + * hash input is exactly ``. + */ +export function deriveDeviceId(anchor: Uint8Array): string { + // Single contiguous buffer: . Built as + // a plain Uint8Array (the prefix is ASCII, so its UTF-8 encoding is + // byte-for-byte the ASCII bytes) to hand `createHash` a plain Uint8Array. + const prefix = Uint8Array.from(DEVICE_ID_PREFIX, (c) => c.charCodeAt(0)) + const input = new Uint8Array(prefix.length + anchor.length) + input.set(prefix, 0) + input.set(anchor, prefix.length) + return createHash('sha256') + .update(input) + .digest('hex') + .slice(0, DEVICE_ID_BYTES * 2) +} + +/** + * Derive the 8-byte VPP (product) identifier from a package id. + * + * `vppId = sha256(packageId)[:8]`, hex-encoded (16 lowercase hex + * chars). Must match `product_id[8]` of the on-device license blob + * so the firmware can bind a written license to its VPP. + */ +export function deriveVppId(packageId: string): string { + // Hash the package id as its UTF-8 (== ASCII) bytes. + return createHash('sha256') + .update(packageId) + .digest('hex') + .slice(0, VPP_ID_BYTES * 2) +} diff --git a/src/backend/editor/license/license-activation-client.ts b/src/backend/editor/license/license-activation-client.ts new file mode 100644 index 000000000..aa193cb2f --- /dev/null +++ b/src/backend/editor/license/license-activation-client.ts @@ -0,0 +1,200 @@ +/** + * Device license-activation client — the editor's only call to the licensing + * backend. + * + * `POST {edge}/vpp-licenses/activate` with `{ deviceId, packageId }` answers + * `{ licensed, deviceId, vppId, license }`, where `license` is the signed 98-byte + * blob base64-encoded. The route is PUBLIC and rate-limited: the editor has no + * login, and authorization is the seat/entitlement check server-side, not a + * token. It is idempotent — an already-licensed device gets the same blob back + * without consuming another seat — which is what makes it safe to call on every + * connect. + * + * NO PROOF OF POSSESSION. The activate body carries no nonce or signature. What + * makes that safe is the bare-metal premise: the hardware anchor is read INSIDE + * the closed license-core and the blob is bound to `deviceId`, so a blob only + * ever runs FULL on the silicon it names. Handing one to whoever knows the id + * benefits nobody with different hardware. + * + * WHAT THAT COSTS, EXPLICITLY: `/activate` is a blob distributor keyed on a + * PUBLIC identifier, which allows early harvesting (collect blobs now, use them + * the day forging an identity gets cheap) and works as an inventory oracle. Both + * are accepted, and both depend on the anchor staying unforgeable. + * + * NO DEV MOCK. An earlier revision carried a dev-build-only signer + * (`OPLC_LICENSE_MOCK` / `OPLC_LICENSE_MOCK_KEY`) that minted real, device-bound + * blobs locally. It is deliberately absent: the local test path now runs the REAL + * backend against the REAL per-VPP key (see `scripts/seed-vpp-licensing-local.ts` + * in autonomy-edge), so the mock bought nothing a developer needs while putting a + * license-minting path one key leak away from unlimited offline licenses. Point + * `OPENPLC_EDGE_API_URL` at the local backend instead — `httpModuleFor` makes a + * plain-http base URL work. + * + * Runs main-side: uses `node:http`/`node:https` and the same base-URL / + * `{ statusCode, data }` envelope conventions as the library catalog client. + */ + +import { LIC_BLOB_SIZE } from '../../shared/debug/license-blob' +import { getEdgeApiBaseUrl } from '../library-manager/desktop-catalog-transport' +import { defaultPortFor, httpModuleFor } from '../utils/http-module' + +/** + * Input to `checkDeviceActivation` — exactly the two fields the wire accepts. + * + * There is deliberately no `vppId` or `keyId` here. Both existed only to feed the + * removed dev signer: `ActivateVppLicenseDto` has no such field, and the backend + * derives its own product and signing-key material from `packageId`. Keeping them + * would advertise a choice the caller does not have. + */ +export interface DeviceActivationInput { + /** 16-byte device id, LOWERCASE hex (from `deriveDeviceId`). */ + deviceId: string + /** VPP package id (e.g. `com.openplc.espressif-licensed`) — `package.id`. */ + packageId: string +} + +/** + * Result of an activation check. Best-effort: transport / backend errors surface + * as `{ licensed: false, error }` rather than throwing, so the licensing routine + * can degrade without a hard failure. + * + * `reason` and `error` are SEPARATE on purpose and callers must keep them apart. + * `reason` is the backend's business answer ("no purchase on record") and means + * demo mode is correct. `error` means we never got an answer — a 429 from the + * rate limiter, a 503 when no signer is configured, a 404 for an unknown package, + * a dropped connection. Collapsing the two tells someone who already owns a + * license to go buy one. + */ +export interface DeviceActivationResult { + licensed: boolean + /** License blob bytes (98 B) when `licensed` — ready to write via FC 0x49. */ + license?: number[] + /** Backend-supplied reason (e.g. "no active subscription"). */ + reason?: string + /** Populated on transport / backend failure (best-effort path). */ + error?: string +} + +/** The exact JSON body `ActivateVppLicenseDto` accepts. */ +interface EdgeActivationRequestBody { + deviceId: string + packageId: string +} + +/** Response shape from the edge activation endpoint. */ +interface EdgeActivationResponse { + licensed: boolean + /** 98-byte license blob, base64-encoded. */ + license?: string + reason?: string +} + +const ACTIVATE_PATH = '/vpp-licenses/activate' +const REQUEST_TIMEOUT_MS = 30_000 + +/** + * Ask the backend whether this device is entitled to a license for this VPP, and + * get the signed blob when it is. + * + * Any failure (route missing → 404, network, non-2xx, bad JSON, wrong blob size) + * resolves to `{ licensed: false, error }` — never throws. + */ +export async function checkDeviceActivation(input: DeviceActivationInput): Promise { + try { + const body: EdgeActivationRequestBody = { deviceId: input.deviceId, packageId: input.packageId } + const raw = await postJson(`${getEdgeApiBaseUrl()}${ACTIVATE_PATH}`, body) + const data = unwrapHttpEnvelope(raw) as Partial | undefined + + if (!data || typeof data.licensed !== 'boolean') { + return { licensed: false, error: 'Unexpected activation response shape' } + } + if (!data.licensed) return { licensed: false, reason: data.reason } + if (typeof data.license !== 'string') { + return { licensed: false, error: 'Activation response missing license blob' } + } + + // `Buffer.from(s, 'base64')` NEVER throws: the decoder silently skips invalid + // characters and tolerates missing padding, so a truncated or corrupted field + // yields a SHORT buffer instead of an error. Writing that to the device + // produces a LIC_CORRUPT rejection whose message points at the hardware, + // giving no hint that the backend sent something malformed. Check the length. + const blob = Buffer.from(data.license, 'base64') + if (blob.length !== LIC_BLOB_SIZE) { + return { + licensed: false, + error: `Activation response license blob is ${blob.length} bytes, expected ${LIC_BLOB_SIZE}`, + } + } + + return { licensed: true, license: Array.from(blob), reason: data.reason } + } catch (err) { + return { licensed: false, error: err instanceof Error ? err.message : String(err) } + } +} + +function postJson(url: string, body: unknown): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url) + const payload = JSON.stringify(body) + const headers: Record = { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(payload)), + Accept: 'application/json', + 'User-Agent': 'OpenPLC-Editor/license-activation', + } + // Sent when available. The route is anonymous today; an account token, when + // there is an authority to issue one, ties the purchase to a user. Without it + // the request still goes out and a 401/404 lands on the best-effort path. + const token = process.env.OPENPLC_EDGE_TOKEN?.trim() + if (token) headers.Authorization = `Bearer ${token}` + + // Scheme-driven, so OPENPLC_EDGE_API_URL can point at a local http backend + // for end-to-end testing. Production hosts stay https either way. + const req = httpModuleFor(parsed).request( + { + hostname: parsed.hostname, + port: parsed.port || defaultPortFor(parsed), + path: parsed.pathname + parsed.search, + method: 'POST', + headers, + }, + (res) => { + let responseBody = '' + res.setEncoding('utf-8') + res.on('data', (chunk: string) => { + responseBody += chunk + }) + res.on('end', () => { + const status = res.statusCode ?? 0 + if (status < 200 || status >= 300) { + reject(new Error(`Activation request failed: ${status} ${res.statusMessage ?? ''}`.trim())) + return + } + try { + resolve(JSON.parse(responseBody)) + } catch (err) { + reject( + new Error(`Activation response was not valid JSON: ${err instanceof Error ? err.message : String(err)}`), + ) + } + }) + }, + ) + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error(`Activation request timed out after ${REQUEST_TIMEOUT_MS}ms`)) + }) + req.on('error', (err) => reject(err)) + req.write(payload) + req.end() + }) +} + +/** autonomy-edge wraps JSON responses in `{ statusCode, data }`. Unwrap once so + * callers see the payload; off-spec responses fall through unchanged. */ +function unwrapHttpEnvelope(raw: unknown): unknown { + if (raw && typeof raw === 'object' && 'data' in raw && 'statusCode' in raw) { + return (raw as { data: unknown }).data + } + return raw +} diff --git a/src/backend/editor/license/license-flow.ts b/src/backend/editor/license/license-flow.ts new file mode 100644 index 000000000..aa572bb37 --- /dev/null +++ b/src/backend/editor/license/license-flow.ts @@ -0,0 +1,344 @@ +/** + * The VPP licensing machine: read what the device holds, decide whether to + * believe it, and recover from the backend when it is absent or wrong. + * + * Runs over an ALREADY-CONNECTED transport — it neither connects nor + * disconnects. The caller holds the link open, so classification, the on-device + * read (0x4A), the backend call and the write (0x49) all happen over a SINGLE + * port open. Pure orchestration over the transport plus the activation client, so + * it is unit-testable with mocks, and it never throws: every failure resolves to + * an outcome. + * + * Lives under `backend/editor` rather than the byte-identical `backend/shared` + * surface because it depends on `deriveDeviceId`, which needs `node:crypto`. + */ + +import type { DeviceLicenseReport, DeviceLicenseState } from '../../../middleware/shared/ports/device-port' +import { crc32IsoHdlc, deserializeLicenseBlob, LIC_BLOB_SIZE, LIC_MAGIC_LE } from '../../shared/debug/license-blob' +import type { DebugLicenseReadResult, DebugLicenseWriteResult } from '../../shared/debug/types' +import { deriveDeviceId, deriveVppId } from './device-identity' +import { checkDeviceActivation } from './license-activation-client' + +/** Just enough of a transport to run the licensing FCs. */ +export interface LicenseReadWritable { + readLicense(): Promise + writeLicense(blob: Uint8Array): Promise +} + +/** SUCCESS status byte of a read-license (0x4A) response = a stored license. */ +const LIC_STATUS_SUCCESS = 0x7e +/** LIC_UNSUPPORTED status byte (no on-device storage backend). */ +const LIC_STATUS_UNSUPPORTED = 0x85 +/** crc32 covers `[payload || signature]` = offsets 0..93; it never covers itself. */ +const LIC_CRC_COVERAGE = 94 + +/** + * The outcome shape is the PORT's `DeviceLicenseState`, not a private one. + * + * Deliberately not a second type mapped at the IPC boundary: the union's whole + * value is that `unlicensed` (the backend says there is no purchase) and + * `check-failed` (we could not find out) cannot be collapsed into each other, and + * a hand-written mapper between two near-identical unions is exactly where that + * distinction gets quietly lost. One type, one meaning, all the way to the badge. + */ +export type { DeviceLicenseReport, DeviceLicenseState } + +export interface DeviceLicenseInput { + /** Raw hardware anchor bytes from the board-id read (FC 0x48). */ + anchor: Uint8Array + /** Reverse-domain VPP package id (`package.id`) from `resolveLicensingTarget`. */ + packageId: string +} + +/** + * Verify a blob the device handed back on 0x4A: magic, crc32, `deviceId` and + * `productId`. + * + * WHY THIS EXISTS. The `0x7E` status byte does NOT mean "the stored license is + * good" — the two targets disagree about what it means. Bare metal validates + * magic + crc32 inside its store read; the Linux runtime checks ONLY that the + * file is 98 bytes long. So on a Pi a blob cloned from another board, or one + * half-written, answers `0x7E`. A caller that believed it would report + * `already-licensed` and NEVER ASK THE BACKEND — skipping the one automatic + * repair path there is. The closed license-core then refuses the blob and the + * board runs demo, stopping actuation 15 minutes in, while the badge says + * "Licensed". + * + * WHAT IT PROVES AND WHAT IT DOES NOT. It proves the bytes are a well-formed + * license FOR THIS DEVICE AND THIS VPP: length, magic, crc32 over 0..93, the + * 16-byte `deviceId` equal to the id derived from the anchor we just read, and + * the 8-byte `productId` equal to the id derived from this package. It does NOT + * verify the ECDSA signature or the `keyId`, so it cannot say the closed gate + * will run FULL — only the license-core can. Anything asserted in the UI must + * stay inside that boundary, which is why the badge says "Licensed" (possession) + * and never "Full mode" (execution). + * + * `productId` is checked only when a `packageId` is known; without one there is + * nothing to compare against and the field is left unverified rather than + * assumed good. + * + * Built on the shared `license-blob.ts` (`deserializeLicenseBlob`, + * `crc32IsoHdlc`) rather than a second parser: that module is byte-identical + * with openplc-web and cross-pinned to the C struct by a golden vector. A + * private re-implementation here is exactly the divergence this avoids. + */ +export function verifyStoredLicenseBlob( + blob: Uint8Array | undefined, + deviceIdHex: string, + packageId: string | undefined, +): { ok: true } | { ok: false; reason: string } { + if (!blob || blob.length !== LIC_BLOB_SIZE) { + // A 0x7E with no (or a short) blob is itself off-contract: the parser only + // fills `blob` when the device sent all `len` bytes. Treat as unverified. + return { ok: false, reason: `stored license is ${blob?.length ?? 0} bytes, expected ${LIC_BLOB_SIZE}` } + } + + const parsed = deserializeLicenseBlob(blob) + + if (parsed.magic !== LIC_MAGIC_LE) { + return { ok: false, reason: 'stored license has no OPLC magic' } + } + + const expectedCrc = crc32IsoHdlc(blob.subarray(0, LIC_CRC_COVERAGE)) + if (parsed.crc32 !== expectedCrc) { + return { ok: false, reason: 'stored license fails its crc32 (truncated or corrupted)' } + } + + const blobDeviceId = bytesToHex(parsed.deviceId) + if (blobDeviceId !== deviceIdHex) { + // The clone case: valid bytes, wrong board. The gate answers DEVICE_MISMATCH. + return { ok: false, reason: `stored license is bound to device ${blobDeviceId}, not ${deviceIdHex}` } + } + + if (packageId) { + const expectedProductId = deriveVppId(packageId) + const blobProductId = bytesToHex(parsed.productId) + if (blobProductId !== expectedProductId) { + return { ok: false, reason: `stored license is for product ${blobProductId}, not ${expectedProductId}` } + } + } + + return { ok: true } +} + +function bytesToHex(bytes: Uint8Array): string { + let out = '' + for (const byte of bytes) out += byte.toString(16).padStart(2, '0') + return out +} + +/** + * Run the licensing step for a licensable board over a live link. + * + * The caller has already decided the board is licensable + * (`resolveLicensingTarget`) and already read its anchor while classifying the + * link, so no round trip is repeated here. + * + * Sequence: + * 1. Derive `deviceId` from the anchor. No anchor -> `check-failed`. + * 2. Read 0x4A. `LIC_UNSUPPORTED` -> `unsupported`. + * 3. If a blob came back, VERIFY it. Good -> `licensed / already-stored`. + * Bad -> fall through to recovery, which is the only automatic repair. + * 4. Ask the backend. No entitlement -> `unlicensed`. Failure -> `check-failed`. + * 5. Write 0x49, then RE-READ and re-verify. Never trust the write. + */ +export async function resolveDeviceLicense( + transport: LicenseReadWritable, + input: DeviceLicenseInput, +): Promise { + const identity = deriveIdentity(input.anchor) + if ('outcome' in identity) return identity + const { deviceId } = identity + + try { + const stored = await readAndVerify(transport, deviceId, input) + if (stored.kind !== 'absent') return { deviceId, outcome: stored.outcome } + + return await recoverLicense(transport, { deviceId, packageId: input.packageId }) + } catch (error) { + return { deviceId, outcome: { state: 'check-failed', error: errorMessage(error) } } + } +} + +/** + * Read what the device is holding and verify it — WITHOUT contacting the backend. + * + * Answers "is this board licensed right now?", which is the question the badge + * asks. Cheap enough for a screen open or a poll, because it is one Modbus frame + * and some arithmetic. + * + * Note what it CANNOT say: `unlicensed` here always carries + * `entitlementChecked: false`, because nobody has asked whether a purchase + * exists. A caller that rendered that as "buy a license" would be guessing — + * offer a refresh instead. + */ +export async function inspectDeviceLicense( + transport: LicenseReadWritable, + input: DeviceLicenseInput, +): Promise { + const identity = deriveIdentity(input.anchor) + if ('outcome' in identity) return identity + const { deviceId } = identity + + try { + const stored = await readAndVerify(transport, deviceId, input) + if (stored.kind === 'absent') { + return { deviceId, outcome: { state: 'unlicensed', entitlementChecked: false, ...stored.detail } } + } + return { deviceId, outcome: stored.outcome } + } catch (error) { + return { deviceId, outcome: { state: 'check-failed', error: errorMessage(error) } } + } +} + +/** + * Derive the licensing identity, or explain why there is none. + * + * An anchor of zero bytes is a REAL reply, not a failure: cores without + * ArduinoUniqueID support, and boards that opt out with `OPENPLC_NO_UNIQUE_ID`, + * answer `id_len = 0` rather than fail to compile, and the link classifier + * correctly counts that as "firmware present". + * + * It is fatal HERE, and must be caught before deriving anything, because + * `sha256(prefix || )` is a CONSTANT: every such board would share one + * device id, so one purchase would license an entire fleet and a license issued + * for one board would verify on all of them. + */ +function deriveIdentity(anchor: Uint8Array): { deviceId: string } | DeviceLicenseReport { + if (anchor.length === 0) { + return { + outcome: { + state: 'check-failed', + error: + 'this board reports no unique hardware id, so no license can be bound to it. ' + + 'A licensed VPP requires a board whose core exposes a unique id.', + }, + } + } + return { deviceId: deriveDeviceId(anchor) } +} + +/** + * The read-and-verify step both entry points share. + * + * `absent` is not an outcome: it means "the device holds nothing usable", and what + * that IMPLIES differs between the two callers — a refresh recovers from it, an + * inspect can only report it. Returning a marker instead of an outcome is what + * keeps that decision at the call site instead of buried here. + */ +type StoredLicenseVerdict = + | { kind: 'settled'; outcome: DeviceLicenseState } + | { kind: 'absent'; detail?: { backendReason?: string } } + +async function readAndVerify( + transport: LicenseReadWritable, + deviceId: string, + input: DeviceLicenseInput, +): Promise { + const stored = await transport.readLicense() + + if (!stored.success) { + return { + kind: 'settled', + outcome: { state: 'check-failed', error: stored.error ?? 'the device did not answer 0x4A' }, + } + } + + if (stored.status === LIC_STATUS_UNSUPPORTED || stored.unsupported) { + return { kind: 'settled', outcome: { state: 'unsupported' } } + } + + if (stored.status === LIC_STATUS_SUCCESS) { + const verdict = verifyStoredLicenseBlob(stored.blob, deviceId, input.packageId) + if (verdict.ok) return { kind: 'settled', outcome: { state: 'licensed', how: 'already-stored' } } + + // Deliberately NOT a failure: a stored blob that does not verify is the case + // recovery exists for (a clone, a half-written file, a licence for another + // VPP). Treating it as absent is the only automatic way this board gets a + // good one. Logged because it is worth seeing in the console. + console.warn( + `[license] the device reported a stored license but it did not verify (${verdict.reason}) — ` + + 'treating it as absent.', + ) + } + + return { kind: 'absent' } +} + +/** + * Ask the backend for a license and write it. Reached when the device holds + * nothing usable — empty, corrupt, or a blob that failed verification. + */ +async function recoverLicense( + transport: LicenseReadWritable, + input: { deviceId: string; packageId: string }, +): Promise { + const { deviceId, packageId } = input + const activation = await checkDeviceActivation({ deviceId, packageId }) + + if (!activation.licensed) { + // A transport/backend failure is NOT the same as "no purchase on record". + // `checkDeviceActivation` already separates `reason` (business) from `error` + // (transport); honour the distinction instead of discarding it one layer up. + if (activation.error) { + return { deviceId, outcome: { state: 'check-failed', error: activation.error } } + } + // The backend WAS asked and said no. `entitlementChecked: true` is what earns + // the UI the right to offer a purchase. + return { deviceId, outcome: { state: 'unlicensed', entitlementChecked: true, backendReason: activation.reason } } + } + + if (!activation.license) { + // Licensed on the backend's word but no blob to write. Nothing to store, and + // nothing we can assert about the device — the board will run demo, so say we + // could not confirm rather than claiming either state. + return { + deviceId, + outcome: { state: 'check-failed', error: 'the backend reported a license but returned no blob to write' }, + } + } + + const write = await transport.writeLicense(Uint8Array.from(activation.license)) + + if (write.unsupported) { + return { deviceId, outcome: { state: 'unsupported' } } + } + if (!write.success) { + return { deviceId, outcome: { state: 'check-failed', error: write.error ?? 'the license write failed' } } + } + + // RE-READ; do not trust the write. 0x49 only STORES bytes: no target checks + // magic, crc32, `deviceId` or `productId` on write. So `write.success` alone + // says nothing about what the board now holds, and asserting possession from it + // means a blob truncated in flight, or signed for another device, reads as + // "Licensed" while the board runs demo. + const readBack = await transport.readLicense() + if (!readBack.success) { + return { + deviceId, + outcome: { + state: 'check-failed', + error: `the license was written but could not be confirmed: the read-back failed (${readBack.error ?? 'no reply'})`, + }, + } + } + + const verdict = verifyStoredLicenseBlob( + readBack.status === LIC_STATUS_SUCCESS ? readBack.blob : undefined, + deviceId, + packageId, + ) + if (verdict.ok) return { deviceId, outcome: { state: 'licensed', how: 'activated' } } + + return { + deviceId, + outcome: { + state: 'check-failed', + error: `the license was written but could not be confirmed on the device: ${verdict.reason}`, + }, + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/backend/editor/modbus/__tests__/read-license-framing.test.ts b/src/backend/editor/modbus/__tests__/read-license-framing.test.ts new file mode 100644 index 000000000..cf00770b3 --- /dev/null +++ b/src/backend/editor/modbus/__tests__/read-license-framing.test.ts @@ -0,0 +1,120 @@ +/** + * Regression test for size-aware RTU framing. + * + * The RTU client used to detect end-of-frame purely by a 10ms idle timeout. A + * large variable-length response — the 98-byte license blob — arriving in + * multiple chunks at 9600 baud, where FTDI/CH340 latency timers push inter-chunk + * gaps to ~16ms, was cut at the first chunk ("Incomplete license blob, got 25"). + * `readLicense()` now passes an `expectedTotalLength` predictor (SUCCESS → total + * = 7 + len) so the read completes once the whole frame has arrived, regardless + * of the gaps between chunks. + * + * This test injects a fake serial port that emits the response in two chunks + * with a REAL 20ms gap (> FRAME_COMPLETE_TIMEOUT_MS = 10ms) and asserts the blob + * is reassembled intact. + */ + +import { EventEmitter } from 'node:events' + +import golden from '../../../shared/debug/__tests__/fixtures/license-golden.json' +import { ModbusRtuClient } from '../modbus-rtu-client' + +const DEBUG_READ_LICENSE = 0x4a +const STATUS_SUCCESS = 0x7e +const SLAVE_ID = 1 + +/** 98-byte golden license blob (`lic_blob_t` serialization). blob[0] === 0x4f ('O'). */ +function goldenBlob(): Buffer { + return Buffer.from(golden.expectedBytesHex, 'hex') +} + +/** + * Build the raw on-the-wire RTU READ_LICENSE frame: + * `[id][FC=0x4a][STATUS=0x7e][len:u16 BE][blob...][crc:2]`. + */ +function buildReadLicenseFrame(blob: Buffer): Buffer { + const frame = Buffer.alloc(3 + 2 + blob.length + 2) + frame.writeUInt8(SLAVE_ID, 0) + frame.writeUInt8(DEBUG_READ_LICENSE, 1) + frame.writeUInt8(STATUS_SUCCESS, 2) + frame.writeUInt16BE(blob.length, 3) + blob.copy(frame as unknown as Uint8Array, 5) + // CRC bytes: the debugger treats a CRC mismatch as non-fatal, so any 2 bytes + // work here — the client strips them regardless. + frame.writeUInt16BE(0x0000, 5 + blob.length) + return frame +} + +/** + * EventEmitter-based fake serial port matching the surface `sendRequestImpl` + * uses: on('data'/'error'), once, removeListener, write, flush, isOpen. + */ +class FakeSerialPort extends EventEmitter { + isOpen = true + emitPlan: (() => void) | null = null + + write(_data: Uint8Array, callback?: (err?: Error | null) => void) { + callback?.(null) + // Kick off the chunked response once the request has been written. + this.emitPlan?.() + } + + flush(callback?: (err?: Error | null) => void) { + callback?.(null) + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe('ModbusRtuClient.readLicense — size-aware multi-chunk framing', () => { + it('reassembles the full 98-byte blob when the frame arrives in two chunks with a >idle-timeout gap', async () => { + const blob = goldenBlob() + expect(blob.length).toBe(98) + + const frame = buildReadLicenseFrame(blob) + // Split so the first chunk (25 bytes) is well short of the 105-byte frame and + // the second arrives after a gap larger than FRAME_COMPLETE_TIMEOUT_MS. + const firstChunk = frame.subarray(0, 25) + const restChunk = frame.subarray(25) + + const port = new FakeSerialPort() + port.emitPlan = () => { + setTimeout(() => port.emit('data', Buffer.from(firstChunk)), 0) + // Real 20ms gap > 10ms idle timeout: the old code truncated here. + void sleep(20).then(() => port.emit('data', Buffer.from(restChunk))) + } + + const client = new ModbusRtuClient({ port: 'x', baudRate: 9600, slaveId: SLAVE_ID, timeout: 500 }) + // Inject the fake port directly, bypassing connect(). + ;(client as unknown as { serialPort: unknown }).serialPort = port + + const result = await client.readLicense() + + expect(result.success).toBe(true) + expect(result.blob).toBeDefined() + expect(result.blob?.length).toBe(98) + expect(result.blob?.[0]).toBe(0x4f) + expect(Buffer.from(result.blob ?? []).equals(blob as unknown as Uint8Array)).toBe(true) + }) + + it('completes a non-SUCCESS reply without waiting for a length that never comes', async () => { + // LIC_EMPTY is [id][FC][STATUS][crc:2] = 5 bytes and carries no len/blob. The + // predictor must recognise that from the STATUS byte alone; keying off `len` + // would stall until the request timeout. + const frame = Buffer.from([SLAVE_ID, DEBUG_READ_LICENSE, 0x83, 0x00, 0x00]) + + const port = new FakeSerialPort() + port.emitPlan = () => { + setTimeout(() => port.emit('data', Buffer.from(frame)), 0) + } + + const client = new ModbusRtuClient({ port: 'x', baudRate: 9600, slaveId: SLAVE_ID, timeout: 500 }) + ;(client as unknown as { serialPort: unknown }).serialPort = port + + const result = await client.readLicense() + + expect(result.success).toBe(true) + expect(result.empty).toBe(true) + expect(result.blob).toBeUndefined() + }) +}) diff --git a/src/backend/editor/modbus/modbus-client.ts b/src/backend/editor/modbus/modbus-client.ts index 2e90011ba..7d6af2cee 100644 --- a/src/backend/editor/modbus/modbus-client.ts +++ b/src/backend/editor/modbus/modbus-client.ts @@ -2,12 +2,18 @@ import { buildGetBoardIdRequest, buildGetStatusRequest, buildPlcSetStateRequest, + buildReadLicenseRequest, + buildWriteLicenseRequest, parseGetBoardIdResponse, parseGetStatusResponse, parsePlcSetStateResponse, + parseReadLicenseResponse, + parseWriteLicenseResponse, } from '@root/backend/shared/debug/modbus-pdu' import type { DebugBoardIdResult, + DebugLicenseReadResult, + DebugLicenseWriteResult, DebugStatusResult, DeviceModbusTransport, Md5ProbeResult, @@ -27,6 +33,10 @@ export enum ModbusFunctionCode { DEBUG_GET_STATUS = 0x46, DEBUG_GET_VERSION = 0x47, DEBUG_GET_BOARD_ID = 0x48, + /** Store a license blob on the device. Write-only; the read back is 0x4A. */ + DEBUG_WRITE_LICENSE = 0x49, + /** Read the stored license blob back off the device. */ + DEBUG_READ_LICENSE = 0x4a, /** Set the runtime run/stop state. Reads go through DEBUG_GET_STATUS (0x46), * which already reports it. */ PLC_SET_STATE = 0x4b, @@ -36,6 +46,12 @@ export enum ModbusDebugResponse { SUCCESS = 0x7e, ERROR_OUT_OF_BOUNDS = 0x81, ERROR_OUT_OF_MEMORY = 0x82, + /** DEBUG_READ_LICENSE only: virgin storage — no license provisioned. */ + LIC_EMPTY = 0x83, + /** DEBUG_READ_LICENSE only: the magic matched but the crc32 did not. */ + LIC_CORRUPT = 0x84, + /** Licensing FCs: the target has no on-device license-store backend. */ + LIC_UNSUPPORTED = 0x85, /** PLC_SET_STATE only: a RUN request was refused because the hardware mode * switch reads STOP. */ REFUSED_BY_SWITCH = 0x86, @@ -476,4 +492,52 @@ export class ModbusTcpClient implements DeviceModbusTransport { return { success: false, error: getErrorMessage(error) } } } + + // ------------------------------------------------------------------------- + // On-device license storage (FC 0x49/0x4A). TCP frame: [MBAP:6][FC@7][...]. + // The wire `len` is BIG-ENDIAN (matches the other FCs) while the blob content + // it frames is little-endian — do not confuse the two. + // + // No size-aware framing is needed here, unlike RTU: `sendTcpRequest` reads the + // MBAP length field, so a 98-byte blob is already framed by the protocol. + // ------------------------------------------------------------------------- + + /** + * FC 0x49 — store a license blob. Storing is NOT validation (see the RTU + * client): read the blob back to learn what the board actually holds. + */ + async writeLicense(blob: Uint8Array): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildWriteLicenseRequest(blob)) + const data = await this.sendTcpRequest(request) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parseWriteLicenseResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** FC 0x4A — read the stored license blob back. */ + async readLicense(): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildReadLicenseRequest()) + const data = await this.sendTcpRequest(request) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parseReadLicenseResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } } diff --git a/src/backend/editor/modbus/modbus-rtu-client.ts b/src/backend/editor/modbus/modbus-rtu-client.ts index 5a516b79f..4a6720f43 100644 --- a/src/backend/editor/modbus/modbus-rtu-client.ts +++ b/src/backend/editor/modbus/modbus-rtu-client.ts @@ -4,12 +4,18 @@ import { buildGetBoardIdRequest, buildGetStatusRequest, buildPlcSetStateRequest, + buildReadLicenseRequest, + buildWriteLicenseRequest, parseGetBoardIdResponse, parseGetStatusResponse, parsePlcSetStateResponse, + parseReadLicenseResponse, + parseWriteLicenseResponse, } from '@root/backend/shared/debug/modbus-pdu' import type { DebugBoardIdResult, + DebugLicenseReadResult, + DebugLicenseWriteResult, DebugStatusResult, DeviceModbusTransport, Md5ProbeResult, @@ -31,6 +37,30 @@ interface ModbusRtuClientOptions { serialPort?: any // Pre-built serial port (e.g. VirtualSerialPort for simulator) } +interface SendRequestOptions { + /** + * Optional size-aware end-of-frame predictor. Given the raw accumulated + * response buffer (the on-the-wire RTU frame, BEFORE the 6-byte TCP-compat + * padding `sendRequestImpl` prepends), returns the total number of bytes the + * complete frame is expected to have, or `null` when not enough bytes have + * arrived yet to make that call. + * + * WHY THIS EXISTS. The default framing declares the frame complete after + * `FRAME_COMPLETE_TIMEOUT_MS` of silence, which is only sound while a frame + * arrives faster than the gap it is measured against. A 98-byte license blob at + * 9600 baud takes ~102 ms to clock out, and the serial layer delivers it in + * several chunks whose inter-chunk gaps exceed 10 ms — so the idle timeout fires + * mid-frame and the response is TRUNCATED. The truncation is silent: the parser + * sees a short buffer and reports a malformed license, which points the blame at + * the board. + * + * With a predictor, the request completes as soon as the declared length has + * fully arrived, and a known-incomplete frame keeps waiting instead of being cut + * off. Callers that omit it fall back to the unchanged idle-timeout framing. + */ + expectedTotalLength?: (raw: Buffer) => number | null +} + const ARDUINO_BOOTLOADER_DELAY_MS = 2500 const MD5_REQUEST_MAX_RETRIES = 3 const MD5_REQUEST_RETRY_DELAY_MS = 500 @@ -177,16 +207,16 @@ export class ModbusRtuClient implements DeviceModbusTransport { private sendRequestMutex: Promise = Promise.resolve() - private async sendRequest(request: Buffer): Promise { + private async sendRequest(request: Buffer, opts?: SendRequestOptions): Promise { return new Promise((resolve, reject) => { this.sendRequestMutex = this.sendRequestMutex.then( - () => this.sendRequestImpl(request).then(resolve, reject), - () => this.sendRequestImpl(request).then(resolve, reject), + () => this.sendRequestImpl(request, opts).then(resolve, reject), + () => this.sendRequestImpl(request, opts).then(resolve, reject), ) }) } - private async sendRequestImpl(request: Buffer): Promise { + private async sendRequestImpl(request: Buffer, opts?: SendRequestOptions): Promise { if (!this.serialPort || !this.serialPort.isOpen) { throw new Error('Serial port is not open') } @@ -211,35 +241,63 @@ export class ModbusRtuClient implements DeviceModbusTransport { reject(new Error('Request timeout')) }, this.timeout) - const onData = (data: Buffer) => { - responseBuffer = Buffer.concat([responseBuffer, data] as unknown as Uint8Array[]) + // Strip the 2-byte CRC trailer, prepend the 6-byte TCP-compat padding the + // rest of the client expects, and resolve. Shared by both the size-aware + // completion and the idle-timeout fallback so they frame identically. + const complete = () => { + clearTimeout(timeoutHandle) + cleanup() - if (frameCompleteTimeout) { - clearTimeout(frameCompleteTimeout) + if (responseBuffer.length < 5) { + reject(new Error('Response too short')) + return } - frameCompleteTimeout = setTimeout(() => { - clearTimeout(timeoutHandle) - cleanup() + const receivedCrc = responseBuffer.readUInt16BE(responseBuffer.length - 2) + const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) - if (responseBuffer.length < 5) { - reject(new Error('Response too short')) - return - } + if (receivedCrc !== calculatedCrc) { + // OpenPLC debugger ignores CRC errors — mismatch is non-fatal + } + + const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) + const paddedResponse = Buffer.alloc(6 + responseWithoutCrc.length) + paddedResponse.fill(0, 0, 6) + responseWithoutCrc.copy(paddedResponse as unknown as Uint8Array, 6) + + resolve(paddedResponse) + } - const receivedCrc = responseBuffer.readUInt16BE(responseBuffer.length - 2) - const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) + const onData = (data: Buffer) => { + responseBuffer = Buffer.concat([responseBuffer, data] as unknown as Uint8Array[]) - if (receivedCrc !== calculatedCrc) { - // OpenPLC debugger ignores CRC errors — mismatch is non-fatal + // Size-aware framing (opt-in): once the caller can predict the full frame + // length, complete as soon as it has fully arrived, and while the frame is + // known-incomplete keep waiting for the remaining bytes rather than letting + // the idle timeout truncate a multi-chunk response. + if (opts?.expectedTotalLength) { + const expected = opts.expectedTotalLength(responseBuffer) + if (expected !== null) { + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + frameCompleteTimeout = null + } + if (responseBuffer.length >= expected) { + complete() + } + return } + } - const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) - const paddedResponse = Buffer.alloc(6 + responseWithoutCrc.length) - paddedResponse.fill(0, 0, 6) - responseWithoutCrc.copy(paddedResponse as unknown as Uint8Array, 6) + // Fallback: idle-timeout end-of-frame detection (unchanged default for + // callers that pass no predictor, or before the predictor has enough + // bytes to decide). + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + } - resolve(paddedResponse) + frameCompleteTimeout = setTimeout(() => { + complete() }, FRAME_COMPLETE_TIMEOUT_MS) } @@ -348,7 +406,19 @@ export class ModbusRtuClient implements DeviceModbusTransport { } const request = this.assembleRequest(functionCode, data) - const response = await this.sendRequest(request) + const response = await this.sendRequest(request, { + // Raw RTU frame (SUCCESS): id@0, FC@1, STATUS@2, lastIndex u16 @3..4, + // tick u32 @5..8, responseSize u16BE @9..10, data @11.., crc 2 -> total = + // 11 + responseSize + 2 = 13 + responseSize. A non-SUCCESS response is + // [id][FC][STATUS][crc:2] = 5 bytes, so key off STATUS. (Mirrors the C + // runtime's debugGetTraceList: mb_frame_len = 11 + responseSize, else 3.) + expectedTotalLength: (raw) => { + if (raw.length < 3) return null + if (raw.readUInt8(2) !== (ModbusDebugResponse.SUCCESS as number)) return 5 + if (raw.length < 11) return null + return 13 + raw.readUInt16BE(9) + }, + }) if (response.length < 9) { return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } @@ -467,6 +537,72 @@ export class ModbusRtuClient implements DeviceModbusTransport { } } + // ------------------------------------------------------------------------- + // On-device license storage (FC 0x49/0x4A). Response offsets account for the + // 6-byte TCP-compat padding sendRequestImpl prepends: FC@7, status@8, payload@9+. + // The wire `len` is BIG-ENDIAN (matches the other FCs) while the blob content + // it frames is little-endian — do not confuse the two. + // ------------------------------------------------------------------------- + + /** + * FC 0x49 — store a license blob. Storing is NOT validation: no target checks + * magic, crc32, `deviceId` or `productId` here, so a `success: true` means only + * that the bytes were accepted. Callers that need to know what the board holds + * read it back with `readLicense()`. + */ + async writeLicense(blob: Uint8Array): Promise { + try { + // buildWriteLicenseRequest() returns [FC][len:u16BE][blob]; assembleRequest + // writes the FC + slaveId itself, so hand it only the trailing payload. + const pdu = buildWriteLicenseRequest(blob) + const payload = Buffer.from(pdu.subarray(1)) + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_WRITE_LICENSE, payload) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + // Strip the 6-byte TCP-compat padding; the pure PDU starts at offset 7. + return parseWriteLicenseResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x4A — read the stored license blob back. Uses the size-aware framing: a + * 98-byte blob does not arrive inside one idle window at low baud rates, and the + * default framing would truncate it silently (see `SendRequestOptions`). + */ + async readLicense(): Promise { + try { + const pdu = buildReadLicenseRequest() + const payload = Buffer.from(pdu.subarray(1)) // bare [FC]; no trailing payload + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_READ_LICENSE, payload) + const response = await this.sendRequest(request, { + // Raw RTU frame: id@0, FC@1, STATUS@2, len u16BE @3..4, blob @5.., crc 2. + // SUCCESS -> total = 1+1+1+2+len+2 = 7+len. A non-SUCCESS response carries + // no len/blob ([id][FC][STATUS][crc:2] = 5 bytes), so key off STATUS. + expectedTotalLength: (raw) => { + if (raw.length < 3) return null + if (raw.readUInt8(2) !== (ModbusDebugResponse.SUCCESS as number)) return 5 + if (raw.length < 5) return null + return 7 + raw.readUInt16BE(3) + }, + }) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + // Strip the 6-byte TCP-compat padding; parse the pure PDU from offset 7. + return parseReadLicenseResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + /** * FC 0x48 DEBUG_GET_BOARD_ID. Bare `[FC]` PDU (no payload). Response offsets * account for the 6-byte TCP-compat padding sendRequestImpl prepends, so the diff --git a/src/backend/editor/utils/__tests__/http-module.test.ts b/src/backend/editor/utils/__tests__/http-module.test.ts new file mode 100644 index 000000000..4f7db6d58 --- /dev/null +++ b/src/backend/editor/utils/__tests__/http-module.test.ts @@ -0,0 +1,38 @@ +import http from 'http' +import https from 'https' + +import { defaultPortFor, httpModuleFor } from '../http-module' + +describe('httpModuleFor', () => { + it('returns the http module for an http: URL', () => { + expect(httpModuleFor('http://localhost:3333/vpp-licenses/activate')).toBe(http) + }) + + it('returns the https module for an https: URL', () => { + expect(httpModuleFor('https://api.autonomylogic.com/vpp-licenses/activate')).toBe(https) + }) + + it('accepts an already-parsed URL', () => { + expect(httpModuleFor(new URL('http://127.0.0.1:3333/x'))).toBe(http) + }) + + // An unparseable or exotic URL must never silently downgrade to plaintext. + it('falls back to https for an unparseable URL', () => { + expect(httpModuleFor('not a url')).toBe(https) + }) + + it('falls back to https for a non-http(s) scheme', () => { + expect(httpModuleFor('ftp://example.com/x')).toBe(https) + }) +}) + +describe('defaultPortFor', () => { + it('is 80 for http and 443 for https', () => { + expect(defaultPortFor('http://localhost/x')).toBe(80) + expect(defaultPortFor('https://localhost/x')).toBe(443) + }) + + it('falls back to 443 when the URL cannot be parsed', () => { + expect(defaultPortFor('not a url')).toBe(443) + }) +}) diff --git a/src/backend/editor/utils/http-module.ts b/src/backend/editor/utils/http-module.ts new file mode 100644 index 000000000..9fb3555a5 --- /dev/null +++ b/src/backend/editor/utils/http-module.ts @@ -0,0 +1,46 @@ +/** + * Pick the Node request module that matches a URL's scheme. + * + * The edge clients (`desktop-catalog-transport`, `license-activation-client`) + * were both written against `https.request` directly. That is right for + * production — every deployed edge host is HTTPS — but it makes the base-URL + * override (`OPENPLC_EDGE_API_URL`) a lie: pointing it at a local backend + * (`http://localhost:3333`, the port the autonomy-edge dev server binds) sends + * a TLS ClientHello to a plain HTTP socket. The connection dies with EPROTO / + * ECONNRESET, the client's catch turns that into a generic failure, and the + * editor silently falls back to demo mode. The result is that the one contract + * we most need to exercise end-to-end cannot be exercised locally at all. + * + * `http.request` and `https.request` share a call signature, so a scheme-driven + * lookup is a drop-in for either call site. + */ + +import http from 'http' +import https from 'https' + +/** Node's `http`/`https` share the `request` signature we rely on. */ +export type NodeRequestModule = Pick + +/** + * The request module for `url`'s scheme. Defaults to `https` for anything that + * is not explicitly `http:` — an unparseable or exotic URL should not silently + * downgrade to plaintext. + */ +export function httpModuleFor(url: string | URL): NodeRequestModule { + try { + const parsed = typeof url === 'string' ? new URL(url) : url + return parsed.protocol === 'http:' ? http : https + } catch { + return https + } +} + +/** Default port for a URL that does not carry one, by scheme. */ +export function defaultPortFor(url: string | URL): number { + try { + const parsed = typeof url === 'string' ? new URL(url) : url + return parsed.protocol === 'http:' ? 80 : 443 + } catch { + return 443 + } +} diff --git a/src/backend/shared/debug/__tests__/fixtures/license-golden.json b/src/backend/shared/debug/__tests__/fixtures/license-golden.json new file mode 100644 index 000000000..911b02a22 --- /dev/null +++ b/src/backend/shared/debug/__tests__/fixtures/license-golden.json @@ -0,0 +1,18 @@ +{ + "_comment": "Golden cross-language fixture for lic_blob_t (OLS-14). Shared by the TS test (license-blob.test.ts) and the C host-test (T04). 'input' is the structured license; 'expectedBytesHex' is the deterministic 98-byte LE serialization produced by serializeLicenseBlob; 'expectedCrc32' is CRC-32/ISO-HDLC over [payload||signature] (offsets 0..93). Any byte here MUST match both the TS serializer and the C memcpy of the packed struct. Do not hand-edit expectedBytesHex.", + "input": { + "magic": 1129074767, + "fmtVersion": 1, + "keyId": 0, + "deviceId": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "productId": [160, 161, 162, 163, 164, 165, 166, 167], + "signature": [ + 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 17 + ], + "crc32": 0 + }, + "expectedBytesHex": "4f504c430100000102030405060708090a0b0c0d0e0fa0a1a2a3a4a5a6a711111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111b6311445", + "expectedCrc32": 1158951350 +} diff --git a/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts b/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts new file mode 100644 index 000000000..7b250e6d6 --- /dev/null +++ b/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts @@ -0,0 +1,103 @@ +/** + * Cross-language parity between the TypeScript license-blob codec and the C + * struct the firmware compiles. + * + * WHY THIS EXISTS. The blob crosses a language boundary: the editor serializes it + * in TypeScript, the board parses it as `lic_blob_t`. A layout disagreement does + * not fail loudly — it produces a blob the board stores happily and the + * license-core then rejects, which surfaces as "the device says it is licensed and + * still runs demo". That is the single most expensive way for these two files to + * drift, so it must fail a test instead. + * + * WHAT THIS CAN AND CANNOT CATCH. It reads `license_blob.h` as text and asserts + * the two sides agree on the sizes, the offsets, the magic and the CRC parameters. + * It cannot catch a compiler that inserts padding — but nothing needs to: the + * header carries both `#pragma pack` and `__attribute__((packed))`, plus + * `LIC_STATIC_ASSERT` on both struct sizes, so padding fails the FIRMWARE BUILD + * on the device toolchain rather than shipping. + * + * A real C host-test compiling the header and comparing bytes against + * `license-golden.json` would be stronger and is the right follow-up; the repo has + * no C test harness to hang one on today. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { crc32IsoHdlc, LIC_BLOB_SIZE, LIC_MAGIC_LE, LIC_PAYLOAD_SIZE } from '../license-blob' + +const HEADER_PATH = join(__dirname, '..', '..', '..', '..', '..', 'resources', 'sources', 'Baremetal', 'license_blob.h') + +const header = readFileSync(HEADER_PATH, 'utf-8') + +/** Read a `#define NAME 0x...u` / decimal value out of the C header. */ +function cDefine(name: string): number { + const match = new RegExp(`#define\\s+${name}\\s+(0x[0-9A-Fa-f]+|\\d+)u?`).exec(header) + if (!match) throw new Error(`${name} not found in license_blob.h`) + return Number(match[1]) +} + +describe('license_blob.h ↔ license-blob.ts parity', () => { + it('agrees on the blob and payload sizes', () => { + expect(cDefine('LIC_BLOB_SIZE')).toBe(LIC_BLOB_SIZE) + expect(cDefine('LIC_PAYLOAD_SIZE')).toBe(LIC_PAYLOAD_SIZE) + }) + + it('agrees on the little-endian magic', () => { + expect(cDefine('LIC_MAGIC_LE')).toBe(LIC_MAGIC_LE) + }) + + it('declares the struct fields in the order and widths the TS serializer writes', () => { + // The offsets the TS side writes at, as the header's own layout table states + // them. Parsed from the table rather than from the struct so a change to + // either the table or the struct that leaves them disagreeing shows up here — + // the table is what a firmware author reads. + const expected: Array<[string, number, number]> = [ + ['magic', 0, 4], + ['fmt_version', 4, 1], + ['key_id', 5, 1], + ['device_id', 6, 16], + ['product_id', 22, 8], + ['signature', 30, 64], + ['crc32', 94, 4], + ] + + for (const [field, offset, size] of expected) { + const row = new RegExp(`^//\\s*${offset}\\s+${field}\\s+\\S+.*?\\s${size}\\s`, 'm') + expect(header).toMatch(row) + } + }) + + it('carries the static assertions that make padding a build failure, not a runtime surprise', () => { + // These are what stop a toolchain that ignores one of the two packing + // directives from silently producing a 100-byte struct. + expect(header).toMatch(/LIC_STATIC_ASSERT\(sizeof\(lic_payload_t\)\s*==\s*30/) + expect(header).toMatch(/LIC_STATIC_ASSERT\(sizeof\(lic_blob_t\)\s*==\s*98/) + expect(header).toMatch(/#pragma pack\(push, 1\)/) + // Anchored to the DECLARATION, not just a mention: the header also discusses + // the attribute in prose, so a looser match passed even after the struct lost + // it. AVR-GCC and xtensa-GCC honour the two directives differently, which is + // why both are required rather than either. + expect(header).toMatch(/typedef struct __attribute__\(\(packed\)\) \{/) + }) + + it('uses the same CRC-32/ISO-HDLC parameters on both sides', () => { + // The reflected polynomial and the init/xorout constants. A different + // polynomial produces a blob whose crc32 the board rejects as CORRUPT — the + // failure mode that looks like flaky hardware. + expect(header).toMatch(/0xEDB88320u/) + expect(header).toMatch(/crc\s*=\s*0xFFFFFFFFu/) + expect(header).toMatch(/crc\s*\^\s*0xFFFFFFFFu/) + + // And the header documents the canonical test vector the TS side satisfies, + // so both implementations are pinned to the same published value. + expect(header).toContain('0xCBF43926') + expect(crc32IsoHdlc(Uint8Array.from([...'123456789'].map((c) => c.charCodeAt(0))))).toBe(0xcbf43926) + }) + + it('states the endianness duality that the two layers must not confuse', () => { + // The blob content is little-endian; the Modbus `len` framing it is + // big-endian. Every place this was got wrong cost a debugging session. + expect(header).toMatch(/blob CONTENT is LITTLE-ENDIAN/) + }) +}) diff --git a/src/backend/shared/debug/__tests__/license-blob.test.ts b/src/backend/shared/debug/__tests__/license-blob.test.ts new file mode 100644 index 000000000..617750774 --- /dev/null +++ b/src/backend/shared/debug/__tests__/license-blob.test.ts @@ -0,0 +1,140 @@ +// jsdom (editor's Jest environment) doesn't ship TextEncoder by default — +// polyfill from Node's util before the CRC test vector uses it. +import { TextEncoder as NodeTextEncoder } from 'node:util' + +if (typeof globalThis.TextEncoder === 'undefined') { + ;(globalThis as { TextEncoder: typeof TextEncoder }).TextEncoder = NodeTextEncoder as unknown as typeof TextEncoder +} + +import { + crc32IsoHdlc, + deserializeLicenseBlob, + LIC_BLOB_SIZE, + LIC_MAGIC_LE, + LIC_PAYLOAD_SIZE, + type LicenseBlob, + serializeLicenseBlob, +} from '../license-blob' +import golden from './fixtures/license-golden.json' + +const TextEnc = globalThis.TextEncoder + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} + +/** Build the LicenseBlob input from the shared golden fixture. */ +function goldenInput(): LicenseBlob { + return { + magic: golden.input.magic, + fmtVersion: golden.input.fmtVersion, + keyId: golden.input.keyId, + deviceId: Uint8Array.from(golden.input.deviceId), + productId: Uint8Array.from(golden.input.productId), + signature: Uint8Array.from(golden.input.signature), + crc32: golden.input.crc32, + } +} + +describe('crc32IsoHdlc', () => { + it('matches the canonical CRC-32/ISO-HDLC test vector for "123456789"', () => { + const crc = crc32IsoHdlc(new TextEnc().encode('123456789')) + expect(crc).toBe(0xcbf43926) + }) + + it('returns an unsigned 32-bit value', () => { + const crc = crc32IsoHdlc(new TextEnc().encode('123456789')) + expect(crc).toBeGreaterThanOrEqual(0) + expect(crc).toBeLessThanOrEqual(0xffffffff) + }) + + it('produces 0 for an empty input (init ^ xorout)', () => { + // CRC of no data: 0xFFFFFFFF ^ 0xFFFFFFFF === 0. + expect(crc32IsoHdlc(new Uint8Array())).toBe(0) + }) +}) + +describe('constants', () => { + it('mirror the C struct sizes and magic', () => { + expect(LIC_BLOB_SIZE).toBe(98) + expect(LIC_PAYLOAD_SIZE).toBe(30) + expect(LIC_MAGIC_LE).toBe(0x434c504f) + }) +}) + +describe('serializeLicenseBlob', () => { + it('produces exactly the golden fixture bytes', () => { + const bytes = serializeLicenseBlob(goldenInput()) + expect(bytes).toHaveLength(LIC_BLOB_SIZE) + expect(bytesToHex(bytes)).toBe(golden.expectedBytesHex) + }) + + it('writes the magic as the four bytes 4F 50 4C 43', () => { + const bytes = serializeLicenseBlob(goldenInput()) + expect(Array.from(bytes.subarray(0, 4))).toEqual([0x4f, 0x50, 0x4c, 0x43]) + }) + + it('recomputes crc32 over [payload||signature] and stores it LE at offset 94', () => { + const bytes = serializeLicenseBlob(goldenInput()) + const view = new DataView(bytes.buffer) + const storedCrc = view.getUint32(94, true) + expect(storedCrc).toBe(golden.expectedCrc32) + // Independent recomputation over offsets 0..93 must agree. + expect(crc32IsoHdlc(bytes.subarray(0, 94))).toBe(golden.expectedCrc32) + }) + + it('ignores the crc32 field on the input (always recomputes)', () => { + const tampered = goldenInput() + tampered.crc32 = 0xdeadbeef + const bytes = serializeLicenseBlob(tampered) + expect(new DataView(bytes.buffer).getUint32(94, true)).toBe(golden.expectedCrc32) + }) +}) + +describe('deserializeLicenseBlob', () => { + it('reproduces the golden input from the golden bytes', () => { + const parsed = deserializeLicenseBlob(hexToBytes(golden.expectedBytesHex)) + expect(parsed.magic).toBe(LIC_MAGIC_LE) + expect(parsed.fmtVersion).toBe(golden.input.fmtVersion) + expect(parsed.keyId).toBe(golden.input.keyId) + expect(Array.from(parsed.deviceId)).toEqual(golden.input.deviceId) + expect(Array.from(parsed.productId)).toEqual(golden.input.productId) + expect(Array.from(parsed.signature)).toEqual(golden.input.signature) + expect(parsed.crc32).toBe(golden.expectedCrc32) + }) + + it('throws on a truncated buffer', () => { + expect(() => deserializeLicenseBlob(new Uint8Array(LIC_BLOB_SIZE - 1))).toThrow(/too short/) + }) +}) + +describe('round-trip serialize -> deserialize', () => { + it('is identical for all fields (magic and crc32 canonicalized)', () => { + const input = goldenInput() + const parsed = deserializeLicenseBlob(serializeLicenseBlob(input)) + + // magic is forced to canonical LIC_MAGIC_LE on serialize; input already uses it. + expect(parsed.magic).toBe(input.magic) + expect(parsed.fmtVersion).toBe(input.fmtVersion) + expect(parsed.keyId).toBe(input.keyId) + expect(Array.from(parsed.deviceId)).toEqual(Array.from(input.deviceId)) + expect(Array.from(parsed.productId)).toEqual(Array.from(input.productId)) + expect(Array.from(parsed.signature)).toEqual(Array.from(input.signature)) + // crc32 is recomputed on serialize; the round-tripped value is the real crc. + expect(parsed.crc32).toBe(golden.expectedCrc32) + }) + + it('re-serializes the deserialized blob to the same bytes (byte-stable)', () => { + const bytes = hexToBytes(golden.expectedBytesHex) + const reserialized = serializeLicenseBlob(deserializeLicenseBlob(bytes)) + expect(bytesToHex(reserialized)).toBe(golden.expectedBytesHex) + }) +}) diff --git a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts index 5daee4a1a..b1fa8fd5a 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts @@ -17,13 +17,17 @@ import { buildGetMd5Request, buildGetStatusRequest, buildGetVersionRequest, + buildReadLicenseRequest, buildSetVariableRequest, + buildWriteLicenseRequest, parseGetBoardIdResponse, parseGetListResponse, parseGetMd5Response, parseGetStatusResponse, parseGetVersionResponse, + parseReadLicenseResponse, parseSetVariableResponse, + parseWriteLicenseResponse, responseFunctionCode, } from '../modbus-pdu' @@ -394,3 +398,175 @@ describe('responseFunctionCode', () => { expect(responseFunctionCode(new Uint8Array(0))).toBeUndefined() }) }) +describe('buildWriteLicenseRequest', () => { + it('emits [FC][len:U16BE][blob] with big-endian length', () => { + // 260-byte blob proves the length is BE (0x01 0x04), not LE. + const blob = new Uint8Array(260).fill(0xab) + blob[0] = 0x4f // 'O' — magic first byte, sanity marker + const buf = buildWriteLicenseRequest(blob) + expect(buf).toHaveLength(3 + 260) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_WRITE_LICENSE) + expect(buf[1]).toBe(0x01) // len hi + expect(buf[2]).toBe(0x04) // len lo (260 = 0x0104) + expect(buf[3]).toBe(0x4f) + expect(buf[buf.length - 1]).toBe(0xab) + }) + + it('handles a zero-length blob', () => { + const buf = buildWriteLicenseRequest(new Uint8Array(0)) + expect(buf).toHaveLength(3) + expect(buf[1]).toBe(0) + expect(buf[2]).toBe(0) + }) +}) + +describe('parseWriteLicenseResponse', () => { + it('returns success on SUCCESS status', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.SUCCESS]) + const result = parseWriteLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.status).toBe(ModbusDebugResponse.SUCCESS) + }) + + it('surfaces an out-of-bounds (TOO_LARGE, 0x81) status as failure', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]) + const result = parseWriteLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.status).toBe(ModbusDebugResponse.ERROR_OUT_OF_BOUNDS) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + }) + + it('surfaces an out-of-memory (0x82) status as failure', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.ERROR_OUT_OF_MEMORY]) + const result = parseWriteLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.status).toBe(ModbusDebugResponse.ERROR_OUT_OF_MEMORY) + expect(result.error).toBe('ERROR_OUT_OF_MEMORY') + }) + + it('treats LIC_EMPTY / LIC_CORRUPT statuses on a WRITE response as failures (not valid write states)', () => { + // EMPTY/CORRUPT are read-side device states; a WRITE that echoes them is + // not SUCCESS, so the write must be reported as failed. + const empty = parseWriteLicenseResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.LIC_EMPTY]), + ) + expect(empty.success).toBe(false) + expect(empty.status).toBe(ModbusDebugResponse.LIC_EMPTY) + + const corrupt = parseWriteLicenseResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.LIC_CORRUPT]), + ) + expect(corrupt.success).toBe(false) + expect(corrupt.status).toBe(ModbusDebugResponse.LIC_CORRUPT) + }) + + it('classifies LIC_UNSUPPORTED (0x85) as success + unsupported (no backend)', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.LIC_UNSUPPORTED]) + const result = parseWriteLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.status).toBe(ModbusDebugResponse.LIC_UNSUPPORTED) + expect(result.unsupported).toBe(true) + }) + + it('rejects a function-code mismatch', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.SUCCESS]) + expect(parseWriteLicenseResponse(buf).success).toBe(false) + }) + + it('rejects a too-short frame', () => { + expect(parseWriteLicenseResponse(new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE])).success).toBe(false) + }) +}) + +describe('buildReadLicenseRequest', () => { + it('emits a bare [FC] frame', () => { + const buf = buildReadLicenseRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_READ_LICENSE) + }) +}) + +describe('parseReadLicenseResponse', () => { + it('extracts the blob on SUCCESS using a BE length', () => { + const blob = new Uint8Array([0x4f, 0x50, 0x4c, 0x43, 0xde, 0xad]) + const buf = new Uint8Array(4 + blob.length) + buf[0] = ModbusFunctionCode.DEBUG_READ_LICENSE + buf[1] = ModbusDebugResponse.SUCCESS + buf[2] = 0x00 // len hi + buf[3] = blob.length // len lo + buf.set(blob, 4) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.empty).toBeUndefined() + expect(result.corrupt).toBeUndefined() + expect(result.blob).toBeDefined() + expect(Array.from(result.blob ?? [])).toEqual(Array.from(blob)) + // magic first byte survives the round-trip (endianness sanity) + expect(result.blob?.[0]).toBe(0x4f) + }) + + it('classifies LIC_EMPTY as success + empty (no blob)', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.LIC_EMPTY]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.empty).toBe(true) + expect(result.blob).toBeUndefined() + }) + + it('classifies LIC_CORRUPT as success + corrupt (no blob)', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.LIC_CORRUPT]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.corrupt).toBe(true) + expect(result.blob).toBeUndefined() + }) + + it('classifies LIC_UNSUPPORTED as success + unsupported (no blob)', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.LIC_UNSUPPORTED]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.unsupported).toBe(true) + expect(result.blob).toBeUndefined() + }) + + it('surfaces an out-of-bounds (TOO_LARGE, 0x81) status as failure with no blob', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.status).toBe(ModbusDebugResponse.ERROR_OUT_OF_BOUNDS) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + expect(result.blob).toBeUndefined() + }) + + it('surfaces an out-of-memory (0x82) status as failure', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.ERROR_OUT_OF_MEMORY]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.status).toBe(ModbusDebugResponse.ERROR_OUT_OF_MEMORY) + }) + + it('rejects a function-code mismatch', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.SUCCESS]) + expect(parseReadLicenseResponse(buf).success).toBe(false) + }) + + it('flags a truncated blob', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.SUCCESS, 0x00, 0x08, 0x01]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.error).toMatch(/Incomplete license blob/) + }) + + it('flags a success frame missing the length field', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.SUCCESS]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.error).toMatch(/at least 4/) + }) + + it('rejects a frame with no status byte at all', () => { + const result = parseReadLicenseResponse(new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) +}) diff --git a/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts b/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts new file mode 100644 index 000000000..21ed46e0a --- /dev/null +++ b/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts @@ -0,0 +1,137 @@ +/** + * License function codes (0x49 write / 0x4A read) over the runtime-v4 debug + * WebSocket. Mocks socket.io-client so the test exercises the REAL PDU framing: + * builder -> spaced-hex envelope -> canned runtime response -> parser. + * + * This is the editor side of the transport-agnostic activation: the same PDUs the + * serial and TCP clients send, so a network target licenses through one code path + * instead of a second, medium-specific one. The runtime answers these FCs at the + * webserver level (covered by the runtime's own tests). + */ +import type { Socket } from 'socket.io-client' + +import { WebSocketDebugTransport } from '../websocket-debug-transport' + +type Handler = (arg: unknown) => void +type Responder = (commandHex: string) => { success: boolean; data?: string; error?: string } + +/** + * Fake Socket.IO socket: auto-connects and answers `debug_command` with whatever + * the active responder returns for the PDU it was handed. + */ +function makeFakeSocket(responder: Responder): Socket { + const handlers: Record = {} + const socket = { + on(event: string, cb: Handler) { + ;(handlers[event] ||= []).push(cb) + if (event === 'connected') setTimeout(() => cb({ status: 'ok' }), 0) + return socket + }, + off(event: string, cb: Handler) { + handlers[event] = (handlers[event] || []).filter((h) => h !== cb) + return socket + }, + emit(event: string, payload: { command: string }) { + if (event === 'debug_command') { + const resp = responder(payload.command) + setTimeout(() => (handlers['debug_response'] || []).forEach((h) => h(resp)), 0) + } + return socket + }, + disconnect() { + return socket + }, + io: { on() {} }, + } + return socket as unknown as Socket +} + +let currentResponder: Responder = () => ({ success: false, error: 'no responder' }) + +jest.mock('socket.io-client', () => ({ + io: jest.fn(() => makeFakeSocket((cmd) => currentResponder(cmd))), +})) + +async function connected(): Promise { + const transport = new WebSocketDebugTransport({ host: '127.0.0.1', port: 8443, token: 'jwt' }) + await transport.connect() + return transport +} + +function toSpacedHex(bytes: number[]): string { + return bytes.map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(' ') +} + +describe('WebSocketDebugTransport license function codes', () => { + it('readLicense (0x4A) parses a full 98-byte blob on SUCCESS', async () => { + const blob = new Uint8Array(98) + for (let i = 0; i < blob.length; i++) blob[i] = i & 0xff + currentResponder = () => ({ success: true, data: toSpacedHex([0x4a, 0x7e, 0x00, 98, ...blob]) }) + + const transport = await connected() + const result = await transport.readLicense() + + expect(result.success).toBe(true) + expect(result.blob?.length).toBe(98) + expect(Array.from(result.blob ?? [])).toEqual(Array.from(blob)) + }) + + it('readLicense (0x4A) maps LIC_EMPTY to a valid empty state, not an error', async () => { + currentResponder = () => ({ success: true, data: '4A 83' }) + + const transport = await connected() + const result = await transport.readLicense() + + expect(result.success).toBe(true) + expect(result.empty).toBe(true) + expect(result.blob).toBeUndefined() + }) + + it('readLicense (0x4A) maps LIC_UNSUPPORTED to a valid state (no store backend)', async () => { + currentResponder = () => ({ success: true, data: '4A 85' }) + + const transport = await connected() + const result = await transport.readLicense() + + expect(result.success).toBe(true) + expect(result.unsupported).toBe(true) + }) + + it('writeLicense (0x49) frames [FC][len:u16BE][blob] and parses SUCCESS', async () => { + const sent: string[] = [] + currentResponder = (cmd) => { + sent.push(cmd) + return { success: true, data: '49 7E' } + } + + const transport = await connected() + const result = await transport.writeLicense(new Uint8Array(98).fill(0xab)) + + const parts = sent[0].split(' ') + expect(parts[0]).toBe('49') + expect(parts[1]).toBe('00') // len high + expect(parts[2]).toBe('62') // len low (98) + expect(parts).toHaveLength(3 + 98) + expect(result.success).toBe(true) + }) + + it('surfaces a runtime-side error as a structured failure rather than throwing', async () => { + currentResponder = () => ({ success: false, error: 'no license backend wired' }) + + const transport = await connected() + const result = await transport.readLicense() + + expect(result.success).toBe(false) + expect(result.error).toContain('no license backend wired') + }) + + it('refuses both calls when the socket is not connected', async () => { + const transport = new WebSocketDebugTransport({ host: '127.0.0.1', port: 8443, token: 'jwt' }) + + await expect(transport.readLicense()).resolves.toEqual({ success: false, error: 'Not connected to target' }) + await expect(transport.writeLicense(new Uint8Array(98))).resolves.toEqual({ + success: false, + error: 'Not connected to target', + }) + }) +}) diff --git a/src/backend/shared/debug/index.ts b/src/backend/shared/debug/index.ts index 3c4dfe443..b3156975f 100644 --- a/src/backend/shared/debug/index.ts +++ b/src/backend/shared/debug/index.ts @@ -1,2 +1,17 @@ +export { + crc32IsoHdlc, + deserializeLicenseBlob, + LIC_BLOB_SIZE, + LIC_MAGIC_LE, + LIC_PAYLOAD_SIZE, + type LicenseBlob, + serializeLicenseBlob, +} from './license-blob' export { ModbusRtuTransport } from './modbus-rtu-transport' -export type { DebugSetResult, DebugTransport, DebugTransportResult } from './types' +export type { + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugSetResult, + DebugTransport, + DebugTransportResult, +} from './types' diff --git a/src/backend/shared/debug/license-blob.ts b/src/backend/shared/debug/license-blob.ts new file mode 100644 index 000000000..0a91a9f2d --- /dev/null +++ b/src/backend/shared/debug/license-blob.ts @@ -0,0 +1,147 @@ +/** + * TS mirror of the firmware `lic_blob_t` on-device license blob (OLS-02). + * + * Byte-identical to the C struct in `resources/sources/Baremetal/license_blob.h`. + * All multi-byte fields are **little-endian**, written explicitly through a + * `DataView` so the result never depends on the host byte order. + * + * The cross-language pin is `__tests__/fixtures/license-golden.json`: the same + * vector is asserted by this module's test and by the firmware host-test, so a + * layout change on either side fails a test instead of producing a blob the + * board silently rejects. + * + * Blob layout (98 bytes, packed, no padding): + * + * | Offset | Field | Type | Size | + * |-------:|-------------|---------------|-----:| + * | 0 | magic | uint32 LE | 4 | 'OPLC' -> bytes 4F 50 4C 43 + * | 4 | fmtVersion | uint8 | 1 | + * | 5 | keyId | uint8 | 1 | signing-key id (rotation) + * | 6 | deviceId | uint8[16] | 16 | + * | 22 | productId | uint8[8] | 8 | vpp id + * | 30 | signature | uint8[64] | 64 | ECDSA P-256 r||s raw + * | 94 | crc32 | uint32 LE | 4 | CRC-32/ISO-HDLC over [payload||signature] (0..93) + * | 98 | (end) | | | + * + * The signed payload is offsets 0..29 (30 bytes). The crc32 covers + * `[payload || signature]` = offsets 0..93 (94 bytes) and never itself. + */ + +/** Total blob size in bytes (`sizeof(lic_blob_t)`). */ +export const LIC_BLOB_SIZE = 98 +/** Signed payload size in bytes (`sizeof(lic_payload_t)`). */ +export const LIC_PAYLOAD_SIZE = 30 +/** + * Magic as a little-endian uint32. The first four bytes of the blob are + * always `4F 50 4C 43` ('OPLC'); read as an LE uint32 that is 0x434C504F. + */ +export const LIC_MAGIC_LE = 0x434c504f + +// Field offsets (mirror the C struct exactly). +const OFF_MAGIC = 0 +const OFF_FMT_VERSION = 4 +const OFF_KEY_ID = 5 +const OFF_DEVICE_ID = 6 +const OFF_PRODUCT_ID = 22 +const OFF_SIGNATURE = 30 +const OFF_CRC32 = 94 + +const DEVICE_ID_SIZE = 16 +const PRODUCT_ID_SIZE = 8 +const SIGNATURE_SIZE = 64 + +export interface LicenseBlob { + /** LE uint32 magic; canonical value is `LIC_MAGIC_LE` (0x434C504F). */ + magic: number + fmtVersion: number + /** signing-key id (enables signing-key rotation). */ + keyId: number + /** 16-byte device identifier. */ + deviceId: Uint8Array + /** 8-byte product identifier (vpp id). */ + productId: Uint8Array + /** 64-byte ECDSA P-256 signature (r||s, raw). */ + signature: Uint8Array + /** CRC-32/ISO-HDLC over `[payload||signature]`. */ + crc32: number +} + +// --------------------------------------------------------------------------- +// CRC-32/ISO-HDLC (a.k.a. CRC-32, zlib/PKZIP) +// poly (reflected) 0xEDB88320 · init 0xFFFFFFFF · refin/refout true · xorout 0xFFFFFFFF +// test vector: crc32IsoHdlc("123456789") === 0xCBF43926 +// Same definition as the firmware bitwise implementation — the golden test +// proves cross-language parity. +// --------------------------------------------------------------------------- + +/** Compute CRC-32/ISO-HDLC over `data`, returned as an unsigned 32-bit number. */ +export function crc32IsoHdlc(data: Uint8Array): number { + let crc = 0xffffffff + for (let i = 0; i < data.length; i++) { + crc ^= data[i] + for (let bit = 0; bit < 8; bit++) { + // Reflected: process LSB first, XOR with poly when the low bit is set. + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1 + } + } + return (crc ^ 0xffffffff) >>> 0 +} + +// --------------------------------------------------------------------------- +// Serialize / deserialize +// --------------------------------------------------------------------------- + +function copyFixed(dst: Uint8Array, offset: number, src: Uint8Array, size: number): void { + // Copy at most `size` bytes; a short source leaves the rest zero-filled. + dst.set(src.subarray(0, size), offset) +} + +/** + * Serialize a `LicenseBlob` into its 98-byte on-wire representation. + * + * All multi-byte fields are written little-endian via explicit + * `DataView.setUint32(off, v, true)`. The `magic` is forced to the canonical + * `LIC_MAGIC_LE`, and the `crc32` is **recomputed** over `[payload||signature]` + * (offsets 0..93) — the `crc32` field on the input is ignored. + */ +export function serializeLicenseBlob(b: LicenseBlob): Uint8Array { + const out = new Uint8Array(LIC_BLOB_SIZE) + const view = new DataView(out.buffer) + + view.setUint32(OFF_MAGIC, LIC_MAGIC_LE, true) + out[OFF_FMT_VERSION] = b.fmtVersion & 0xff + out[OFF_KEY_ID] = b.keyId & 0xff + copyFixed(out, OFF_DEVICE_ID, b.deviceId, DEVICE_ID_SIZE) + copyFixed(out, OFF_PRODUCT_ID, b.productId, PRODUCT_ID_SIZE) + copyFixed(out, OFF_SIGNATURE, b.signature, SIGNATURE_SIZE) + + // Recompute crc32 over [payload || signature] = offsets 0..93 (94 bytes). + const crc = crc32IsoHdlc(out.subarray(0, OFF_CRC32)) + view.setUint32(OFF_CRC32, crc, true) + + return out +} + +/** + * Deserialize a 98-byte blob into a `LicenseBlob`. All multi-byte fields are + * read little-endian. `deviceId` / `productId` / `signature` are fresh copies + * (not views onto `buf`). Does not validate magic or crc32 — that is the + * caller's / firmware's responsibility. + */ +export function deserializeLicenseBlob(buf: Uint8Array): LicenseBlob { + if (buf.length < LIC_BLOB_SIZE) { + throw new Error(`Invalid license blob: too short (${buf.length} bytes, expected ${LIC_BLOB_SIZE})`) + } + + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) + + return { + magic: view.getUint32(OFF_MAGIC, true), + fmtVersion: buf[OFF_FMT_VERSION], + keyId: buf[OFF_KEY_ID], + deviceId: buf.slice(OFF_DEVICE_ID, OFF_DEVICE_ID + DEVICE_ID_SIZE), + productId: buf.slice(OFF_PRODUCT_ID, OFF_PRODUCT_ID + PRODUCT_ID_SIZE), + signature: buf.slice(OFF_SIGNATURE, OFF_SIGNATURE + SIGNATURE_SIZE), + crc32: view.getUint32(OFF_CRC32, true), + } +} diff --git a/src/backend/shared/debug/modbus-pdu.ts b/src/backend/shared/debug/modbus-pdu.ts index ed2bb916e..5c3eabc9c 100644 --- a/src/backend/shared/debug/modbus-pdu.ts +++ b/src/backend/shared/debug/modbus-pdu.ts @@ -26,6 +26,18 @@ * plcSetState request: [FC=0x4b] [state: U8] (0 = STOP, 1 = RUN) * plcSetState response: [FC=0x4b] [status] [plcState: U8] [switchPosition: U8] * + * writeLicense request: [FC=0x49] [len: U16BE] [blob...] + * writeLicense response: [FC=0x49] [status] + * + * readLicense request: [FC=0x4a] + * readLicense response: [FC=0x4a] [status] [len: U16BE] [blob...] (SUCCESS) + * [FC=0x4a] [status] (otherwise) + * + * The license `len` is BIG-ENDIAN like every other length on this wire, while + * the blob it frames is little-endian content (see license-blob.ts). The two + * conventions coexist by design: the framing is Modbus, the payload is a C + * struct. + * * set request: [FC=0x42] [arr: U8] [elem: U16BE] [force: U8] [dataLen: U16BE] [value...] * set response: [FC=0x42] [status] * @@ -47,6 +59,8 @@ import { detectTargetEndian, type TargetEndian } from '../../../frontend/utils/e import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from '../simulator/types' import type { DebugBoardIdResult, + DebugLicenseReadResult, + DebugLicenseWriteResult, DebugSetResult, DebugStatusResult, DebugTransportResult, @@ -167,6 +181,29 @@ export function buildGetBoardIdRequest(): Uint8Array { return buf } +/** + * Build a write-license request (FC 0x49). + * PDU: `[FC][len:U16BE][blob...]`. + * + * The `len` on the wire is BIG-ENDIAN (matches every other debug FC), even + * though the blob *content* is little-endian (see license-blob.ts). Do not + * confuse the two: `writeU16BE` is deliberate here. + */ +export function buildWriteLicenseRequest(blob: Uint8Array): Uint8Array { + const buf = alloc(3 + blob.length) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_WRITE_LICENSE) + writeU16BE(buf, 1, blob.length) + buf.set(blob, 3) + return buf +} + +/** Build a read-license request (FC 0x4A). Bare `[FC]` PDU — no payload. */ +export function buildReadLicenseRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_READ_LICENSE) + return buf +} + // --------------------------------------------------------------------------- // Parse responses // --------------------------------------------------------------------------- @@ -388,6 +425,90 @@ export function parseGetBoardIdResponse(data: Uint8Array): DebugBoardIdResult { return { success: true, boardId, boardIdHex } } +/** + * Parse a write-license response (FC 0x49). + * Layout: `[FC][status]`. SUCCESS → `{ success: true, status }`. LIC_UNSUPPORTED + * (the board has no backend) is a valid device state → `{ success: true, + * unsupported: true }`, not a transport error. Any other non-SUCCESS status is a + * device-side failure surfaced as `{ success: false, error }`. + */ +export function parseWriteLicenseResponse(data: Uint8Array): DebugLicenseWriteResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_WRITE_LICENSE) { + return { success: false, error: 'Function code mismatch' } + } + + if (status === ModbusDebugResponse.LIC_UNSUPPORTED) { + return { success: true, status, unsupported: true } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, status, error: statusError(status) } + } + + return { success: true, status } +} + +/** + * Parse a read-license response (FC 0x4A). + * Layout (OK): `[FC][status=SUCCESS][len:U16BE][blob...]`. + * Layout (other): `[FC][status]` — no len, no blob. + * + * `len` is BIG-ENDIAN (readU16BE) — the wire convention — while the blob it + * frames is little-endian content. LIC_EMPTY / LIC_CORRUPT / LIC_UNSUPPORTED are + * valid device states (`success: true` with the corresponding flag), not + * transport errors. + */ +export function parseReadLicenseResponse(data: Uint8Array): DebugLicenseReadResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_READ_LICENSE) { + return { success: false, error: 'Function code mismatch' } + } + + if (status === ModbusDebugResponse.LIC_EMPTY) { + return { success: true, status, empty: true } + } + + if (status === ModbusDebugResponse.LIC_CORRUPT) { + return { success: true, status, corrupt: true } + } + + if (status === ModbusDebugResponse.LIC_UNSUPPORTED) { + return { success: true, status, unsupported: true } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, status, error: statusError(status) } + } + + if (data.length < 4) { + return { success: false, status, error: `Incomplete license response (${data.length} bytes, expected at least 4)` } + } + + const len = readU16BE(data, 2) + if (data.length < 4 + len) { + return { + success: false, + status, + error: `Incomplete license blob (expected ${len} bytes, got ${data.length - 4})`, + } + } + + return { success: true, status, blob: data.slice(4, 4 + len) } +} + /** * Extract the function code from a Modbus PDU response. * Returns `undefined` if the buffer is empty. diff --git a/src/backend/shared/debug/types.ts b/src/backend/shared/debug/types.ts index 1674ed6b9..9795bb88e 100644 --- a/src/backend/shared/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -70,6 +70,48 @@ export interface DebugBoardIdResult { error?: string } +/** + * Result of a write-license call (FC 0x49). The device stores the raw blob + * bytes; `status` is the ModbusDebugResponse code the target returned + * (SUCCESS/ERROR_OUT_OF_BOUNDS/ERROR_OUT_OF_MEMORY). `unsupported` (status + * LIC_UNSUPPORTED) means the board has no license-store backend — a valid + * device state (`success: true`), not a transport failure. + * + * A `success: true` here means ONLY "the bytes were accepted for storage". No + * target validates magic, crc32, `deviceId` or `productId` on write, so a caller + * that needs to know what the board now holds must read it back (FC 0x4A) and + * verify — see the write path in the licensing flow. + */ +export interface DebugLicenseWriteResult { + success: boolean + status?: number + unsupported?: boolean + error?: string +} + +/** + * Result of a read-license call (FC 0x4A). `blob` is present only on SUCCESS. + * `empty` (status LIC_EMPTY) means virgin storage — no license provisioned; + * `corrupt` (status LIC_CORRUPT) means the magic matched but the crc32 failed. + * `unsupported` (status LIC_UNSUPPORTED) means the board has no license-store + * backend at all. All three are `success: true` — they are valid device + * states, not transport failures — the caller distinguishes via the flags. + * + * A SUCCESS status does NOT mean the stored license is good: the two targets + * disagree about what they check before answering it (bare metal validates magic + * + crc32; the Linux runtime only checks the file length). Callers must verify + * the returned bytes themselves. + */ +export interface DebugLicenseReadResult { + success: boolean + status?: number + empty?: boolean + corrupt?: boolean + unsupported?: boolean + blob?: Uint8Array + error?: string +} + /** * Result of an MD5-probe call. The `md5` is the runtime's program hash; * `targetEndian` is the byte order detected from the 2-byte sentinel the @@ -128,6 +170,15 @@ export interface DeviceChannelTransport { /** Run/stop command (FC 0x4b). Reads go through `getStatus()`. Optional for * the same reason as `getStatus`. */ setPlcState?(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise + /** Read the stored VPP license blob (FC 0x4A). Optional here because not every + * medium carries it — but unlike run/stop, every medium that CAN is expected + * to: licensing is a property of the device, not of the target family, so the + * Modbus clients and the runtime-v4 WebSocket all implement it (see + * `DeviceModbusTransport`, where it is required). */ + readLicense?(): Promise + /** Store a VPP license blob (FC 0x49). Optional for the same reason as + * `readLicense`. Storing does not validate — read back to confirm. */ + writeLicense?(blob: Uint8Array): Promise } /** @@ -175,6 +226,12 @@ export interface DeviceDebugChannel extends DeviceChannelTransport { * `DeviceChannelTransport`: both Modbus clients implement run/stop, and only the * runtime-v4 WebSocket (a different protocol, driving run/stop over REST) does * not. + * + * `readLicense` / `writeLicense` are required for a different reason: the license + * FCs are transport-agnostic by design, so device activation runs identically on + * serial and on TCP. A Modbus link that could not carry them would give the + * licensing flow a second, target-dependent shape — which is the divergence the + * one-transport-interface refactor exists to prevent. */ export interface DeviceModbusTransport extends Omit, @@ -182,6 +239,8 @@ export interface DeviceModbusTransport getBoardId(): Promise getStatus(): Promise setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise + readLicense(): Promise + writeLicense(blob: Uint8Array): Promise /** * The two payload-carrying operations, restated for the main process. * diff --git a/src/backend/shared/debug/websocket-debug-transport.ts b/src/backend/shared/debug/websocket-debug-transport.ts index 4192c0df3..8c02f33ef 100644 --- a/src/backend/shared/debug/websocket-debug-transport.ts +++ b/src/backend/shared/debug/websocket-debug-transport.ts @@ -26,12 +26,24 @@ import { getErrorMessage } from '../../../frontend/utils/get-error-message' import { buildGetListRequest, buildGetMd5Request, + buildReadLicenseRequest, buildSetVariableRequest, + buildWriteLicenseRequest, parseGetListResponse, parseGetMd5Response, + parseReadLicenseResponse, parseSetVariableResponse, + parseWriteLicenseResponse, } from './modbus-pdu' -import type { DebugSetResult, DebugTransport, DebugTransportResult, DeviceDebugChannel, Md5ProbeResult } from './types' +import type { + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugSetResult, + DebugTransport, + DebugTransportResult, + DeviceDebugChannel, + Md5ProbeResult, +} from './types' const REQUEST_TIMEOUT_MS = 5000 const CONNECT_TIMEOUT_MS = 5000 @@ -145,6 +157,28 @@ export class WebSocketDebugTransport implements DebugTransport, DeviceDebugChann ) } + // License function codes (0x49/0x4A), byte-identical to the serial/TCP + // clients: the runtime answers them at the webserver level, but the editor sees + // ONE transport-agnostic contract, so the licensing flow has a single shape on + // every target instead of a network-specific branch. + // + // `getBoardId` (0x48) stays absent on purpose: a runtime-v4 target's identity + // comes from the REST login, so it never answers that FC. That is why + // `DeviceChannelTransport` declares the license methods optional — this class + // implements the pair it can serve without claiming the one it cannot. + + async readLicense(): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + + return this.sendCommand(buildReadLicenseRequest(), (bytes) => parseReadLicenseResponse(bytes), 'resolve') + } + + async writeLicense(blob: Uint8Array): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + + return this.sendCommand(buildWriteLicenseRequest(blob), (bytes) => parseWriteLicenseResponse(bytes), 'resolve') + } + /** * Send a Modbus PDU over the `debug_command` event and parse the * matching `debug_response`. `errorMode` controls whether a @@ -157,11 +191,9 @@ export class WebSocketDebugTransport implements DebugTransport, DeviceDebugChann * client method routes through here, so the hex encoding / * timeout / event registration logic lives in exactly one place. */ - private sendCommand( - pdu: Uint8Array, - parse: (bytes: Uint8Array) => T, - errorMode: 'resolve', - ): Promise + private sendCommand< + T extends DebugTransportResult | DebugSetResult | DebugLicenseReadResult | DebugLicenseWriteResult, + >(pdu: Uint8Array, parse: (bytes: Uint8Array) => T, errorMode: 'resolve'): Promise private sendCommand( pdu: Uint8Array, parse: (bytes: Uint8Array) => T, diff --git a/src/backend/shared/hardware/__tests__/board-info-resolver.test.ts b/src/backend/shared/hardware/__tests__/board-info-resolver.test.ts index ae35251fd..e140c3733 100644 --- a/src/backend/shared/hardware/__tests__/board-info-resolver.test.ts +++ b/src/backend/shared/hardware/__tests__/board-info-resolver.test.ts @@ -360,6 +360,87 @@ describe('BoardInfoResolver', () => { ]) }) + // --------------------------------------------------------------------- + // Licensing fields (hal.licenseStore / hal.licenseKeyId) + // + // `hal.licenseStore` resolves to BUILD INPUTS and nothing else. There is no + // capability mirroring it: every licensable VPP targets hardware that + // persists a licence, so a second derived boolean would be true wherever + // `isLicensable` is. These pin that the paths resolve and that the + // capability block is passed through untouched. + // --------------------------------------------------------------------- + + /** A licensed-VPP device, with whatever licensing fields the test is about. */ + function licensedDevice(hal: Record) { + return { + id: 'esp32-generic', + name: 'ESP32 Generic', + preview: 'p.png', + target: { type: 'arduino-cli', core: 'esp32:esp32', platform: 'esp32:esp32:esp32' }, + hal: { type: 'arduino-hal', source: 'hal/arduino/esp32.cpp', ...hal }, + capabilities: { isLicensable: true }, + } as PackageManifest['devices'][number] + } + + function resolveLicensed(hal: Record) { + const pkg = makePkg() + const manifest = makeManifest({ devices: [licensedDevice(hal)] }) + return makeResolver({}, makePackageManager([pkg], { [pkg.packageId]: manifest })).resolve('ESP32 Generic') + } + + it('resolves hal.licenseStore to package-relative paths and flips the licenseStore capability', () => { + const info = resolveLicensed({ + licenseStore: 'hal/arduino/license_store_esp32.cpp', + licenseKeyId: 'espressif-licensed-2026', + }) + + // Asserted through the same platform-supplied resolver the production code + // is handed, which is the actual contract — hard-coding a joined path bakes + // in POSIX assumptions the resolver does not make. + expect(info.licenseStoreFiles).toEqual([packageRelative(PKG_PATH, 'hal/arduino/license_store_esp32.cpp')]) + expect(info.licenseKeyId).toBe('espressif-licensed-2026') + // The capability block is whatever the manifest declared — nothing derived + // into it, so it cannot disagree with `licenseStoreFiles`. + expect(info.capabilities).toEqual({ isLicensable: true }) + }) + + it('accepts hal.licenseStore as an array of sources', () => { + const info = resolveLicensed({ licenseStore: ['hal/a.cpp', 'hal/b.cpp'] }) + + expect(info.licenseStoreFiles).toEqual([ + packageRelative(PKG_PATH, 'hal/a.cpp'), + packageRelative(PKG_PATH, 'hal/b.cpp'), + ]) + expect(info.capabilities).toEqual({ isLicensable: true }) + }) + + it('resolves no store files for a licensable VPP that ships no backend', () => { + // A PACKAGING fault, not a device state: the compiler warns, and the board + // links the weak default and reports its storage as missing. + const info = resolveLicensed({}) + + expect(info.licenseStoreFiles).toBeUndefined() + expect(info.capabilities).toEqual({ isLicensable: true }) + }) + + it('treats a blank licenseStore as absent rather than resolving it to the package root', () => { + // `''` would resolve to PKG_PATH itself and read as "storage present", + // advertising a backend that is not there. + const info = resolveLicensed({ licenseStore: ' ' }) + + expect(info.licenseStoreFiles).toBeUndefined() + }) + + it('does not invent a capability block for a device that declares nothing', () => { + const pkg = makePkg() + const manifest = makeManifest() + const info = makeResolver({}, makePackageManager([pkg], { [pkg.packageId]: manifest })).resolve('Arduino Mega') + + expect(info.capabilities).toBeUndefined() + expect(info.licenseStoreFiles).toBeUndefined() + expect(info.licenseKeyId).toBeUndefined() + }) + it('omits platformOptions when the manifest does not declare any', () => { const pkg = makePkg() const manifest = makeManifest({ diff --git a/src/backend/shared/hardware/board-info-resolver.ts b/src/backend/shared/hardware/board-info-resolver.ts index c241b2f9d..df34bd473 100644 --- a/src/backend/shared/hardware/board-info-resolver.ts +++ b/src/backend/shared/hardware/board-info-resolver.ts @@ -160,6 +160,21 @@ export interface BoardBuildInfo { * library dir, linked via a 2nd `--library`. Its presence marks an arduino * prebuilt board (the source HAL still compiles as the integration layer). */ precompiledLibraryDir?: string + /** Resolved paths of the VPP's on-device license-storage backend sources + * (`device.hal.licenseStore`), injected into the Baremetal sketch so they + * define the STRONG `license_store_*` symbols over the weak default. + * + * This is the ONE representation of "this VPP ships storage". There is + * deliberately no capability mirroring it: every licensable VPP targets + * hardware that persists a licence, so a second derived boolean would be + * true wherever `isLicensable` is and only add a way for the two to + * disagree. */ + licenseStoreFiles?: string[] + /** Per-VPP signing key id (`device.hal.licenseKeyId`). Informational in the + * editor: the activation request carries only `{ deviceId, packageId }` and + * the backend resolves its own key. Carried for the build side and for + * diagnosing a board that stores a blob and still runs demo. */ + licenseKeyId?: string /** Exact Arduino core version to install/verify before linking a prebuilt * arduino library (ABI-locked). From `target.coreVersion`. */ coreVersion?: string @@ -303,6 +318,17 @@ export class BoardInfoResolver { if (flags) info.compilerFlags = flags if (device.hal.define) info.define = device.hal.define if (device.hal.extraArduinoLibraries) info.extraArduinoLibraries = device.hal.extraArduinoLibraries + if (device.hal.licenseKeyId) info.licenseKeyId = device.hal.licenseKeyId + + // `hal.licenseStore` resolves to build inputs and nothing else. It used to + // ALSO derive a `capabilities.licenseStore` boolean; that is gone, because + // every licensable VPP targets hardware that persists a licence, so the + // boolean was true wherever `isLicensable` was and its only job was one + // diagnostic sentence. + const licenseStoreFiles = normalizeToArray(device.hal.licenseStore) + if (licenseStoreFiles.length > 0) { + info.licenseStoreFiles = licenseStoreFiles.map((file) => resolveRel(pkg.path, file)) + } if (device.capabilities) info.capabilities = device.capabilities if (device.hal.pluginType === 'python' || device.hal.pluginType === 'native') { @@ -341,3 +367,14 @@ export class BoardInfoResolver { return out } } + +/** + * A manifest field declared as `string | string[]` as a flat list of non-empty + * entries. Blank strings are dropped rather than resolved: a `licenseStore: ""` + * would otherwise resolve to the package root and read as "storage present". + */ +function normalizeToArray(value: string | string[] | undefined): string[] { + if (value === undefined) return [] + const list = Array.isArray(value) ? value : [value] + return list.filter((entry) => entry.trim().length > 0) +} diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index a64336794..ca3a5eee9 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -1159,4 +1159,133 @@ describe('ModbusRtuClient', () => { expect(result.error).toContain('timeout') }) }) + + // ----------------------------------------------------------------------- + // On-device license storage (FC 0x49 write / 0x4A read) + // + // The end-to-end coverage against a real license store needs firmware built + // with the store backend compiled in, so without these the wire framing here + // is unexercised on an ordinary test run. + // ----------------------------------------------------------------------- + describe('writeLicense / readLicense', () => { + /** A recognisable 98-byte blob whose first byte is the 'O' of the LE magic. */ + function sampleBlob(): Uint8Array { + const blob = new Uint8Array(98) + for (let i = 0; i < blob.length; i++) blob[i] = i & 0xff + blob[0] = 0x4f + return blob + } + + it('frames a write as [FC][len:u16BE][blob] and reports SUCCESS', async () => { + await connectClient() + const blob = sampleBlob() + const written: number[][] = [] + port._interceptWrite = (data: Uint8Array) => { + written.push(Array.from(data)) + setTimeout( + () => + port._emit( + 'data', + buildResponse(1, ModbusFunctionCode.DEBUG_WRITE_LICENSE, new Uint8Array([ModbusDebugResponse.SUCCESS])), + ), + 0, + ) + } + + const result = await client.writeLicense(blob) + + // [slaveId][FC][len hi][len lo][blob...] + CRC. The length is BIG-endian + // even though the blob content it frames is little-endian. + expect(written[0][1]).toBe(ModbusFunctionCode.DEBUG_WRITE_LICENSE) + expect(written[0][2]).toBe(0x00) + expect(written[0][3]).toBe(98) + expect(written[0][4]).toBe(0x4f) + expect(result).toMatchObject({ success: true, status: ModbusDebugResponse.SUCCESS }) + }) + + it('reports LIC_UNSUPPORTED on a write as a valid device state, not a failure', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_WRITE_LICENSE, new Uint8Array([ModbusDebugResponse.LIC_UNSUPPORTED])), + ) + + const result = await client.writeLicense(sampleBlob()) + + expect(result).toMatchObject({ success: true, unsupported: true }) + }) + + it('reads the stored blob back byte-for-byte', async () => { + await connectClient() + const blob = sampleBlob() + const payload = new Uint8Array(3 + blob.length) + payload[0] = ModbusDebugResponse.SUCCESS + payload[1] = 0x00 // len hi + payload[2] = blob.length // len lo + payload.set(blob, 3) + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_READ_LICENSE, payload)) + + const result = await client.readLicense() + + expect(result.success).toBe(true) + expect(Array.from(result.blob ?? [])).toEqual(Array.from(blob)) + // The LE uint32 magic 0x434C504F serializes to 4F 50 4C 43, so a leading + // 0x4F proves the byte order survived the write -> store -> read path. + expect(result.blob?.[0]).toBe(0x4f) + }) + + it('classifies LIC_EMPTY as virgin storage rather than an error', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_READ_LICENSE, new Uint8Array([ModbusDebugResponse.LIC_EMPTY])), + ) + + const result = await client.readLicense() + + expect(result).toMatchObject({ success: true, empty: true }) + expect(result.blob).toBeUndefined() + }) + + it('classifies LIC_CORRUPT as a valid device state rather than an error', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_READ_LICENSE, new Uint8Array([ModbusDebugResponse.LIC_CORRUPT])), + ) + + const result = await client.readLicense() + + expect(result).toMatchObject({ success: true, corrupt: true }) + }) + + it('rejects a read whose declared length exceeds the bytes received', async () => { + await connectClient() + // len says 98, only 2 blob bytes follow — the truncation must be reported, + // not silently handed on as a short "license". + autoRespond( + buildResponse( + 1, + ModbusFunctionCode.DEBUG_READ_LICENSE, + new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00, 98, 0x4f, 0x50]), + ), + ) + + const result = await client.readLicense() + + expect(result.success).toBe(false) + expect(result.error).toContain('Incomplete license blob') + }) + + it('returns an error on a read timeout', async () => { + await connectClient() + const result = await client.readLicense() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + + it('returns an error on a write timeout', async () => { + await connectClient() + const result = await client.writeLicense(sampleBlob()) + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) }) diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts index 8de172813..3f963fcb6 100644 --- a/src/backend/shared/simulator/modbus-rtu-client.ts +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -1,5 +1,18 @@ -import { buildPlcSetStateRequest, parsePlcSetStateResponse } from '@root/backend/shared/debug/modbus-pdu' -import type { DebugStatusResult, Md5ProbeResult, PlcControlResult } from '@root/backend/shared/debug/types' +import { + buildPlcSetStateRequest, + buildReadLicenseRequest, + buildWriteLicenseRequest, + parsePlcSetStateResponse, + parseReadLicenseResponse, + parseWriteLicenseResponse, +} from '@root/backend/shared/debug/modbus-pdu' +import type { + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugStatusResult, + Md5ProbeResult, + PlcControlResult, +} from '@root/backend/shared/debug/types' import { detectTargetEndian } from '@root/frontend/utils/endian' import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from './types' @@ -590,4 +603,48 @@ export class ModbusRtuClient { return { success: false, error: error instanceof Error ? error.message : String(error) } } } + + /** + * FC 0x49 DEBUG_WRITE_LICENSE. Storing is not validation — read the blob back + * to learn what the target now holds. + */ + async writeLicense(blob: Uint8Array): Promise { + try { + // buildWriteLicenseRequest returns [FC][len:u16BE][blob]; assembleRequest + // writes the FC + slaveId itself, so hand it only the trailing payload. + const pdu = buildWriteLicenseRequest(blob) + const response = await this.sendRequest( + this.assembleRequest(ModbusFunctionCode.DEBUG_WRITE_LICENSE, pdu.subarray(1)), + ) + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + return parseWriteLicenseResponse(response.subarray(7)) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + /** + * FC 0x4A DEBUG_READ_LICENSE. Bare `[FC]` request; the response carries the + * blob framed by a big-endian length (the content itself is little-endian). + * + * No size-aware framing here, unlike the editor's serial client: this transport + * runs against an in-process virtual port, so a 98-byte reply arrives in one + * piece and there is no inter-chunk gap to be truncated by. + */ + async readLicense(): Promise { + try { + const pdu = buildReadLicenseRequest() + const response = await this.sendRequest( + this.assembleRequest(ModbusFunctionCode.DEBUG_READ_LICENSE, pdu.subarray(1)), + ) + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + return parseReadLicenseResponse(response.subarray(7)) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } } diff --git a/src/backend/shared/simulator/types.ts b/src/backend/shared/simulator/types.ts index e6609f8f8..126ac4661 100644 --- a/src/backend/shared/simulator/types.ts +++ b/src/backend/shared/simulator/types.ts @@ -7,6 +7,12 @@ export enum ModbusFunctionCode { DEBUG_GET_STATUS = 0x46, DEBUG_GET_VERSION = 0x47, DEBUG_GET_BOARD_ID = 0x48, + /** Store a license blob on the device (VPP licensing). Write-only: the read + * back is DEBUG_READ_LICENSE, and it is a SEPARATE round trip on purpose — + * 0x49 only stores bytes, it validates nothing. */ + DEBUG_WRITE_LICENSE = 0x49, + /** Read the stored license blob back off the device. */ + DEBUG_READ_LICENSE = 0x4a, /** Set the runtime run/stop state. Reads go through DEBUG_GET_STATUS (0x46), * which already reports the state — there is deliberately no second FC for * querying it. */ @@ -17,6 +23,13 @@ export enum ModbusDebugResponse { SUCCESS = 0x7e, ERROR_OUT_OF_BOUNDS = 0x81, ERROR_OUT_OF_MEMORY = 0x82, + /** DEBUG_READ_LICENSE only: virgin storage — no license has been provisioned. */ + LIC_EMPTY = 0x83, + /** DEBUG_READ_LICENSE only: the magic matched but the crc32 did not. */ + LIC_CORRUPT = 0x84, + /** Licensing FCs: the target has no on-device license-store backend at all. + * A valid device state, not a transport failure. */ + LIC_UNSUPPORTED = 0x85, /** PLC_SET_STATE only: a RUN request was refused because the hardware mode * switch reads STOP. */ REFUSED_BY_SWITCH = 0x86, diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx new file mode 100644 index 000000000..2b58fc15b --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx @@ -0,0 +1,185 @@ +import type { DeviceLicenseReport } from '@root/middleware/shared/ports/device-port' +import { fireEvent, render, screen } from '@testing-library/react' + +import { DeviceLicenseStatus } from '../components/device-license-status' + +const DEVICE_ID = '659a3520540f803625ddc34081e893d3' +const BUY_URL = `https://edge.example.com/buy?vppId=com.openplc.espressif-licensed&deviceId=${DEVICE_ID}` + +function setup(report: DeviceLicenseReport | null, overrides: { isChecking?: boolean; buyUrl?: string | null } = {}) { + const onBuy = jest.fn() + const onRecheck = jest.fn() + render( + , + ) + return { onBuy, onRecheck } +} + +function expand() { + fireEvent.click(screen.getByRole('button', { name: 'Licence status' })) +} + +describe('DeviceLicenseStatus', () => { + it('renders nothing before any licensing call has landed', () => { + // The state every non-licensable board stays in, and a licensable one before + // Connect. A placeholder badge would invite the user to read meaning into a + // check that never happened. + const { container } = render( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('shows progress while a check is in flight with nothing known yet', () => { + setup(null, { isChecking: true }) + expect(screen.getByText('Checking licence…')).toBeTruthy() + }) + + it('labels a stored, verified licence as Licensed', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }) + expect(screen.getByText('Licensed')).toBeTruthy() + }) + + it('labels a definitive negative as Not licensed', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: true } }) + expect(screen.getByText('Not licensed')).toBeTruthy() + }) + + it('labels an unanswered check as a FAILURE, not as Not licensed', () => { + // Three states, deliberately not two: collapsing these either tells a paying + // customer to buy again or hides a real failure behind a reassuring badge. + setup({ outcome: { state: 'check-failed', error: 'Request timeout' } }) + expect(screen.getByText('Licence check failed')).toBeTruthy() + expect(screen.queryByText('Not licensed')).toBeNull() + }) + + it('labels missing storage as a FIRMWARE fault, distinctly from having no licence', () => { + // "Not storable" read as a hardware limitation and sent us looking at a + // NodeMCU that stores licences fine — the image was built without the backend. + setup({ deviceId: DEVICE_ID, outcome: { state: 'unsupported' } }) + expect(screen.getByText('Storage missing')).toBeTruthy() + expect(screen.queryByText('Not licensed')).toBeNull() + }) + + it('never claims to know the execution mode the board is running in', () => { + // The editor verifies POSSESSION, not the ECDSA signature — it cannot know + // whether the closed licence-core will run FULL. No label may say otherwise. + const outcomes: DeviceLicenseReport['outcome'][] = [ + { state: 'licensed', how: 'already-stored' }, + { state: 'licensed', how: 'activated' }, + { state: 'unlicensed', entitlementChecked: true }, + { state: 'unsupported' }, + { state: 'check-failed', error: 'x' }, + ] + + for (const outcome of outcomes) { + const { container, unmount } = render( + , + ) + expect(container.textContent ?? '').not.toMatch(/full mode|unlocked|demo mode/i) + unmount() + } + }) + + describe('details panel', () => { + it('exposes the device id and copies it on request', () => { + const writeText = jest.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + + setup({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }) + expand() + + // The id is what a support ticket needs and what the /buy page accepts pasted. + expect(screen.getByText(DEVICE_ID)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + expect(writeText).toHaveBeenCalledWith(DEVICE_ID) + }) + + it('omits the device id when there was no anchor to derive one from', () => { + setup({ outcome: { state: 'check-failed', error: 'no unique hardware id' } }) + expand() + expect(screen.queryByText('Device ID')).toBeNull() + }) + + it('always offers a re-check', () => { + const { onRecheck } = setup({ deviceId: DEVICE_ID, outcome: { state: 'check-failed', error: 'x' } }) + expand() + fireEvent.click(screen.getByRole('button', { name: 'Check again' })) + expect(onRecheck).toHaveBeenCalledTimes(1) + }) + + it('disables the re-check while one is already running', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: true } }, { isChecking: true }) + expand() + expect(screen.getByRole('button', { name: 'Check again' }).hasAttribute('disabled')).toBe(true) + }) + + it('offers a purchase ONLY when the backend confirmed there is no entitlement', () => { + const { onBuy } = setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: true } }) + expand() + fireEvent.click(screen.getByRole('button', { name: 'Buy licence' })) + expect(onBuy).toHaveBeenCalledTimes(1) + }) + + it('does NOT offer a purchase when nobody asked whether one exists', () => { + // Offering to buy here is a guess, and the wrong one for anyone who paid. + setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: false } }) + expand() + expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull() + }) + + it('does NOT offer a purchase when the check failed', () => { + setup({ outcome: { state: 'check-failed', error: 'Activation request failed: 429' } }) + expand() + expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull() + }) + + it('does NOT offer a purchase when the device cannot store a licence', () => { + // Buying would not make the device able to store one. + setup({ deviceId: DEVICE_ID, outcome: { state: 'unsupported' } }) + expand() + expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull() + }) + + it('hides the purchase button when no valid link could be built', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: true } }, { buyUrl: null }) + expand() + expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull() + }) + + it('tells an unsupported device to rebuild, and does not blame the hardware', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'unsupported' } }) + expand() + expect(screen.getByText(/rebuild and upload/i)).toBeTruthy() + expect(screen.getByText(/This hardware supports it/)).toBeTruthy() + }) + + it("quotes the backend's reason when it gave one", () => { + setup({ + deviceId: DEVICE_ID, + outcome: { state: 'unlicensed', entitlementChecked: true, backendReason: 'no active subscription' }, + }) + expand() + expect(screen.getByText(/no active subscription/)).toBeTruthy() + }) + + it('states that a failed check is not the same as having no licence', () => { + setup({ outcome: { state: 'check-failed', error: 'Request timeout' } }) + expand() + expect(screen.getByText(/not the same as having no licence/)).toBeTruthy() + }) + }) +}) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 6a14c8240..2692486a7 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -9,6 +9,7 @@ import { MinusIcon } from '../../../../../../assets/icons/interface/Minus' import { PlusIcon } from '../../../../../../assets/icons/interface/Plus' import { RefreshIcon } from '../../../../../../assets/icons/interface/Refresh' import { useDeviceConnect } from '../../../../../../hooks/use-device-connect' +import { useDeviceLicense } from '../../../../../../hooks/use-device-license' import { boardSelectors, pinSelectors } from '../../../../../../hooks/use-store-selectors' import { useOpenPLCStore } from '../../../../../../store' import type { RuntimeConnection } from '../../../../../../store/slices/device/types' @@ -25,6 +26,7 @@ import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '../.. import { PluginStatsPanel } from '../../../../../_molecules/plugin-stats-panel' import { ScanCycleStats } from '../../../../../_molecules/scan-cycle-stats' import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-editor-slot' +import { DeviceLicenseStatus } from './components/device-license-status' import { PinMappingTable } from './components/pin-mapping-table' /** @@ -71,6 +73,10 @@ const Board = memo(function () { status: serialStatus, } = useDeviceConnect(currentBoardInfo) + // VPP licensing. Inert for every board whose VPP is not sold licensed, which is + // every built-in board — `isLicensable` gates the whole affordance. + const licensing = useDeviceLicense(currentBoardInfo) + // Whether this target exposes the GPIO pin-mapping table. Arduino boards // enable it via their preset; runtime-v4 GPIO boards (e.g. the Raspberry // Pi HAL) opt in with `capabilities.pinMapping` in their VPP manifest. @@ -720,6 +726,19 @@ const Board = memo(function () { : {})} > + {/* Licensing sits next to the link indicator because they answer + adjacent questions about the same device — but it renders + nothing at all unless this board's VPP is sold licensed AND a + check has landed, so a free board's screen is unchanged. */} + {licensing.isLicensable ? ( + void licensing.buy()} + onRecheck={() => void licensing.refresh()} + /> + ) : null} ) : null} diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/components/device-license-status.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/components/device-license-status.tsx new file mode 100644 index 000000000..668880b7d --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/components/device-license-status.tsx @@ -0,0 +1,226 @@ +/** + * Licence status for the device screen: a quiet, monochrome line next to + * "Connected", plus a details panel with the device id and the actions the + * outcome warrants. + * + * WHAT "LICENSED" IS ALLOWED TO MEAN HERE. It means the main process read the + * blob off the device (FC 0x4A) and verified its magic, crc32, `deviceId` and + * `productId` — i.e. the device HOLDS a well-formed licence for this board and + * this VPP. The ECDSA signature and the key id are NOT checked (only the closed + * license-core can), so a blob signed by a key the board does not trust would + * read "Licensed" here and the board would still run demo. + * + * Therefore every label in this component is about POSSESSION — Licensed / Not + * licensed / Licence check failed — and never about EXECUTION. No string here may + * say "full mode", "unlocked" or "running in demo": the editor cannot know that. + * + * THREE states, deliberately not two. "Not licensed" is an ANSWER; "Licence check + * failed" is the ABSENCE of one. Collapsing them either tells a paying customer to + * buy again or hides a real failure behind a reassuring badge. + */ + +import type { DeviceLicenseReport } from '@root/middleware/shared/ports/device-port' +import { useState } from 'react' + +import { cn } from '../../../../../../../utils/cn' + +/** Filled shield + check — "this device holds a licence". */ +function ShieldLicensedIcon({ size = 14 }: { size?: number }) { + return ( + + ) +} + +/** Outline shield + dash — "no licence on this device". */ +function ShieldUnlicensedIcon({ size = 14 }: { size?: number }) { + return ( + + ) +} + +/** Outline shield + question — "we could not tell". */ +function ShieldUnknownIcon({ size = 14 }: { size?: number }) { + return ( + + ) +} + +export interface DeviceLicenseStatusProps { + /** Null before any licensing call has landed — nothing is rendered. */ + report: DeviceLicenseReport | null + isChecking: boolean + /** Null when no valid purchase link can be built; the button is then hidden. */ + buyUrl: string | null + onBuy: () => void + onRecheck: () => void +} + +/** The label + icon for an outcome. One place, so no branch can drift. */ +function describeOutcome(report: DeviceLicenseReport): { + label: string + Icon: typeof ShieldLicensedIcon + /** Whether the label reads as a definitive negative (drives the dashed underline). */ + negative: boolean + detail: string +} { + switch (report.outcome.state) { + case 'licensed': + return { + label: 'Licensed', + Icon: ShieldLicensedIcon, + negative: false, + detail: + report.outcome.how === 'activated' + ? 'A licence was just written to this device and read back to confirm it.' + : 'This device is holding a licence issued for it and for this VPP.', + } + case 'unlicensed': + return { + label: 'Not licensed', + Icon: ShieldUnlicensedIcon, + negative: true, + detail: report.outcome.entitlementChecked + ? `No licence is registered for this device.${ + report.outcome.backendReason ? ` The licence server said: ${report.outcome.backendReason}` : '' + }` + : 'This device is not holding a valid licence. Nobody has checked yet whether one was purchased for it.', + } + case 'unsupported': + // The label points at the FIRMWARE, not the board. "Not storable" read as a + // hardware limitation and cost a debugging session on a NodeMCU that stores + // licences fine — the image was simply built without the backend. + return { + label: 'Storage missing', + Icon: ShieldUnknownIcon, + negative: false, + detail: + 'The firmware running on this device reports no licence storage. This hardware supports it, ' + + 'so the image was built without the storage backend — rebuild and upload.', + } + case 'check-failed': + return { + label: 'Licence check failed', + Icon: ShieldUnknownIcon, + negative: false, + detail: `${report.outcome.error}\n\nThis is not the same as having no licence — nothing on the device has changed.`, + } + } +} + +export function DeviceLicenseStatus({ report, isChecking, buyUrl, onBuy, onRecheck }: DeviceLicenseStatusProps) { + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(false) + + // Nothing has run: every non-licensable board stays here, and so does a + // licensable one before Connect. Showing a placeholder badge would invite the + // user to read meaning into a check that never happened. + if (!report) { + return isChecking ? ( + + Checking licence… + + ) : null + } + + const { label, Icon, negative, detail } = describeOutcome(report) + const deviceId = report.deviceId + + // The purchase button appears ONLY where buying is the honest next step: the + // backend was asked and reported no entitlement. On `check-failed` or an + // unchecked `unlicensed` it would be a guess, and a costly one. + const offerPurchase = !!buyUrl && report.outcome.state === 'unlicensed' && report.outcome.entitlementChecked === true + + return ( +
+ + + {expanded ? ( +
+
+ + {label} +
+ +

{detail}

+ + {deviceId ? ( +
+ Device ID +
+ + {deviceId} + + +
+
+ ) : null} + +
+ + {offerPurchase ? ( + + ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/src/frontend/hooks/__tests__/use-device-connect.test.ts b/src/frontend/hooks/__tests__/use-device-connect.test.ts index f72b09517..0d6d51c49 100644 --- a/src/frontend/hooks/__tests__/use-device-connect.test.ts +++ b/src/frontend/hooks/__tests__/use-device-connect.test.ts @@ -16,14 +16,22 @@ const mockSetDeviceConnectionStatus = jest.fn((status: string, port: string | nu /** The status the store ended up in — what the Connect button actually reads. */ const currentStatus = (): string => (mockState.deviceConnection as { status: string }).status +const mockStartLicenseCheck = jest.fn() +const mockSetLicenseReport = jest.fn() +const mockClearDeviceLicense = jest.fn() + const mockState: Record = { deviceDefinitions: { configuration: { deviceBoard: 'Test Board', communicationPort: 'COM5', vendorScreenData: {} } }, deviceConnection: { status: 'disconnected', port: null }, + deviceLicense: { phase: 'idle', report: null }, runtimeConnection: { ipAddress: '192.168.0.128', jwtToken: 'jwt-tok' }, modalActions: { openModal: mockOpenModal }, consoleActions: { addLog: mockAddLog }, deviceActions: { setDeviceConnectionStatus: mockSetDeviceConnectionStatus, + startDeviceLicenseCheck: mockStartLicenseCheck, + setDeviceLicenseReport: mockSetLicenseReport, + clearDeviceLicense: mockClearDeviceLicense, }, } @@ -47,12 +55,22 @@ const serialCandidate = { /** Shape the hook consumes: what can be tried now, and what needs input first. */ let mockResolution: unknown = { candidates: [serialCandidate], awaitingInput: [] } +const mockReadLicense = jest.fn() +const mockRefreshLicense = jest.fn() +const mockOpenExternalLink = jest.fn().mockResolvedValue({ success: true }) + jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) jest.mock('@root/middleware/shared/providers/platform-context', () => ({ useDevice: () => ({ connect: mockConnect, disconnect: mockDisconnect, onConnectionStatus: mockOnConnectionStatus, + readLicense: mockReadLicense, + refreshLicense: mockRefreshLicense, + }), + useSystem: () => ({ + getEdgeFrontendUrl: () => 'https://edge.example.com', + openExternalLink: mockOpenExternalLink, }), })) jest.mock('../../services/device-link-resolution', () => ({ @@ -279,4 +297,163 @@ describe('useDeviceConnect', () => { expect(mockSetDeviceConnectionStatus).not.toHaveBeenCalledWith('disconnected', null) }) }) + + // ----------------------------------------------------------------------- + // VPP licensing + // ----------------------------------------------------------------------- + describe('licensing', () => { + /** A board whose VPP is sold licensed. `board` above deliberately is not. */ + const licensedBoard = { + compiler: 'arduino-cli', + core: 'esp32:esp32', + preview: '', + specs: {}, + debug: {}, + capabilities: { isLicensable: true }, + vpp: { + packageId: 'com.openplc.espressif-licensed', + vendor: 'Espressif', + deviceId: 'esp32-generic', + packagePath: '/pkg', + screens: {}, + moduleSystem: { enabled: false, maxSlots: 0, modules: [] }, + }, + } as unknown as BoardInfo + + it('runs NO licensing traffic for a board whose VPP is not sold licensed', async () => { + // The common case, and the reason licensability is the first gate: a free + // board's connect must be exactly what it was before licensing existed. + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockRefreshLicense).not.toHaveBeenCalled() + expect(mockReadLicense).not.toHaveBeenCalled() + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('settles the licence over the held link after a successful connect', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense.mockResolvedValue({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + expect(mockRefreshLicense).toHaveBeenCalledWith({ packageId: 'com.openplc.espressif-licensed' }) + expect(mockSetLicenseReport).toHaveBeenCalledWith({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + // A licensed device is a silent success — no dialog on every connect. + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('does not touch licensing when no firmware answered', async () => { + // There is nothing to ask: the flash dialog is the whole message, and a + // licence dialog stacked on top of it would bury it. + mockConnect.mockResolvedValue({ status: 'no-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + expect(mockRefreshLicense).not.toHaveBeenCalled() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Firmware Detected' }) + }) + + it('does not touch licensing when the device never answered at all', async () => { + mockConnect.mockResolvedValue({ status: 'no-response' }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + expect(mockRefreshLicense).not.toHaveBeenCalled() + }) + + it('prompts about demo mode and offers a purchase when the backend reports no entitlement', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense.mockResolvedValue({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: true }, + }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + const [, props] = mockOpenModal.mock.calls[0] + expect(props).toMatchObject({ title: 'No Licence For This Device' }) + expect((props as { buttons: string[] }).buttons).toEqual(['Buy Licence', 'Continue in Demo Mode']) + + // Buying opens the device-BOUND purchase page: the id derived main-side is + // what makes the purchase attach to this board rather than to nothing. + latestOnResponse()(0) + await Promise.resolve() + expect(mockOpenExternalLink).toHaveBeenCalledWith( + 'https://edge.example.com/buy?vppId=com.openplc.espressif-licensed&deviceId=659a3520540f803625ddc34081e893d3', + ) + }) + + it('reports a failed check as a failure, never as "not licensed"', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense.mockResolvedValue({ + outcome: { state: 'check-failed', error: 'Activation request failed: 429' }, + }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + const [, props] = mockOpenModal.mock.calls[0] + expect(props).toMatchObject({ title: 'Licence Check Failed' }) + expect((props as { buttons: string[] }).buttons).not.toContain('Buy Licence') + }) + + it('re-runs the flow when the user retries, and explains the NEW outcome', async () => { + // What makes a purchase completed in the browser land without a reconnect. + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense + .mockResolvedValueOnce({ outcome: { state: 'check-failed', error: 'Request timeout' } }) + .mockResolvedValueOnce({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'activated' }, + }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + expect(mockOpenModal).toHaveBeenCalledTimes(1) + + latestOnResponse()(0) + await Promise.resolve() + await Promise.resolve() + + expect(mockRefreshLicense).toHaveBeenCalledTimes(2) + // The retry succeeded, and success is silent — no second dialog. + expect(mockOpenModal).toHaveBeenCalledTimes(1) + }) + + it('turns a rejected licensing IPC call into check-failed rather than losing it', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense.mockRejectedValue(new Error('bridge is gone')) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + expect(mockSetLicenseReport).toHaveBeenCalledWith({ + outcome: { state: 'check-failed', error: 'bridge is gone' }, + }) + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'Licence Check Failed' }) + }) + + it('drops the licence on a DELIBERATE disconnect', async () => { + // The user is done with this device; a badge left behind would assert + // possession for hardware nothing is talking to. A link that merely DROPS + // keeps it — that is the device slice's job, not this hook's. + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.disconnect() + + expect(mockClearDeviceLicense).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/frontend/hooks/use-device-connect.ts b/src/frontend/hooks/use-device-connect.ts index f6ba9e5af..f957467f2 100644 --- a/src/frontend/hooks/use-device-connect.ts +++ b/src/frontend/hooks/use-device-connect.ts @@ -21,6 +21,8 @@ import { useCallback } from 'react' import { resolveDeviceLinkWithUx } from '../services/device-link-resolution' import { useOpenPLCStore } from '../store' import { requestDeviceFlash } from '../utils/device-connect-events' +import { explainLicenseOutcome } from '../utils/license-outcome-dialog' +import { useDeviceLicense } from './use-device-license' export interface UseDeviceConnectResult { /** Open + hold the link for the given board. Never throws. */ @@ -38,7 +40,9 @@ export function useDeviceConnect(boardInfo: BoardInfo | undefined): UseDeviceCon const device = useDevice() const openModal = useOpenPLCStore((s) => s.modalActions.openModal) const setDeviceConnectionStatus = useOpenPLCStore((s) => s.deviceActions.setDeviceConnectionStatus) + const clearDeviceLicense = useOpenPLCStore((s) => s.deviceActions.clearDeviceLicense) const status = useOpenPLCStore((s) => s.deviceConnection.status) + const licensing = useDeviceLicense(boardInfo) const connect = useCallback(async (): Promise => { const deviceBoard = useOpenPLCStore.getState().deviceDefinitions.configuration.deviceBoard @@ -119,6 +123,32 @@ export function useDeviceConnect(boardInfo: BoardInfo | undefined): UseDeviceCon if (buttonIndex === 0) requestDeviceFlash() }, }) + return + } + + // The link is up and a firmware answered. If this board's VPP is sold + // licensed, settle its licence now — over the link that is already open. + // + // AWAITED, not fired and forgotten: the whole flow runs on one held Modbus + // link, and letting the connect return first invites the user to click + // Upload or Debug into the middle of a read/write sequence. It is also why + // this is the LAST thing connect does — a non-licensable board (the common + // case) never reaches it, and pays nothing. + if (licensing.isLicensable) { + const report = await licensing.refresh() + if (report) { + explainLicenseOutcome(report, { + openModal, + buy: licensing.buy, + // A retry re-runs the flow and explains the NEW outcome, so a + // transient failure or a purchase completed in the browser resolves + // without disconnecting. + retry: async () => { + const next = await licensing.refresh() + if (next) explainLicenseOutcome(next, { openModal, buy: licensing.buy }) + }, + }) + } } } finally { // 'connecting' is set OPTIMISTICALLY above, and normally only the main process @@ -140,12 +170,17 @@ export function useDeviceConnect(boardInfo: BoardInfo | undefined): UseDeviceCon setDeviceConnectionStatus('disconnected', null) } } - }, [boardInfo, device, openModal, setDeviceConnectionStatus]) + }, [boardInfo, device, licensing, openModal, setDeviceConnectionStatus]) const disconnect = useCallback(async (): Promise => { await device.disconnect() setDeviceConnectionStatus('disconnected', null) - }, [device, setDeviceConnectionStatus]) + // A DELIBERATE disconnect is the one link event that should drop the licence + // too: the user is done with this device, and leaving a badge behind would + // assert possession for hardware nothing is talking to. A link that merely + // DROPS keeps it — see `clearDeviceConnection` in the device slice. + clearDeviceLicense() + }, [clearDeviceLicense, device, setDeviceConnectionStatus]) return { connect, diff --git a/src/frontend/hooks/use-device-license.ts b/src/frontend/hooks/use-device-license.ts new file mode 100644 index 000000000..88e30a9b2 --- /dev/null +++ b/src/frontend/hooks/use-device-license.ts @@ -0,0 +1,161 @@ +/** + * useDeviceLicense — the renderer side of the VPP licensing flow. + * + * Owns the four things the UI needs and nothing else: + * - whether licensing applies to this board at all (`isLicensable`); + * - the last landed report, from the store; + * - `check()` (read + verify, local) and `refresh()` (full flow, may reach the + * network and write); + * - `buy()`, which opens the device-bound purchase page. + * + * Deliberately NOT folded into `useDeviceConnect`: that hook is about resolving + * and holding a link, this one about what the device is entitled to run. The only + * coupling is one call after a successful connect. + */ +import type { DeviceLicenseReport } from '@root/middleware/shared/ports/device-port' +import type { BoardInfo } from '@root/middleware/shared/ports/types' +import { useDevice, useSystem } from '@root/middleware/shared/providers/platform-context' +import { resolveLicensingTarget } from '@root/middleware/shared/utils/licensing' +import { useCallback, useMemo } from 'react' + +import { useOpenPLCStore } from '../store' +import { buildLicenseBuyUrl } from '../utils/license-buy-url' + +export interface UseDeviceLicenseResult { + /** Whether the selected board's VPP participates in licensing at all. When + * false every other member here is inert and the UI shows nothing. */ + isLicensable: boolean + /** Set when the manifest is broken: declares licensable with no package id. */ + configurationError: string | null + /** The last landed report, or null before anything has run. */ + report: DeviceLicenseReport | null + /** True while a call is in flight. */ + isChecking: boolean + /** + * Read + verify what the device holds. Local only, never the network. + * + * Returns the report as well as storing it, so a caller that must ACT on the + * outcome (the connect flow, which prompts about demo mode) does not have to + * read it back out of the store — a read that races the very set it follows. + * `null` when licensing does not apply to this board. + */ + check: () => Promise + /** Full flow: read, verify, and recover from the backend when needed. */ + refresh: () => Promise + /** + * The purchase link for the report currently in the store — what the badge's + * button uses. Null when no valid link can be built. + */ + buyUrl: string | null + /** + * Open the device-bound purchase page. + * + * `deviceId` is a parameter, not read from the store, and that is load-bearing. + * A caller reached straight out of a licensing call — the demo dialog the connect + * flow opens — is holding a report the store has only just been told about, and + * the closure it captured still sees the PREVIOUS value (null, on a first + * connect). Taking the id explicitly is what stops "Buy Licence" from silently + * doing nothing on the one path where it matters most. + */ + buy: (deviceId?: string) => Promise +} + +export function useDeviceLicense(boardInfo: BoardInfo | undefined): UseDeviceLicenseResult { + const device = useDevice() + const system = useSystem() + const startCheck = useOpenPLCStore((s) => s.deviceActions.startDeviceLicenseCheck) + const setReport = useOpenPLCStore((s) => s.deviceActions.setDeviceLicenseReport) + const phase = useOpenPLCStore((s) => s.deviceLicense.phase) + const report = useOpenPLCStore((s) => s.deviceLicense.report) + + const target = useMemo(() => resolveLicensingTarget(boardInfo), [boardInfo]) + + /** + * Run one licensing call, whichever it is. + * + * `startCheck` before and `setReport` after, unconditionally — an early return + * on a non-licensable board would leave `phase: 'checking'` set forever, and the + * UI disables its actions on that. + */ + const run = useCallback( + async (which: 'check' | 'refresh'): Promise => { + if (!target.licensable) return null + + const call = which === 'check' ? device.readLicense : device.refreshLicense + if (!call) { + // A platform that holds no device link (the port declares both optional). + // Reported rather than ignored: silence here reads as "no license". + const report: DeviceLicenseReport = { + outcome: { state: 'check-failed', error: 'This platform cannot check device licenses.' }, + } + setReport(report) + return report + } + + startCheck() + const request = { packageId: target.packageId } + try { + const report = await call(request) + setReport(report) + return report + } catch (error) { + // The IPC call itself failed. Still a report, and still `check-failed`: + // the badge must never fall back to "not licensed" because a channel died. + const report: DeviceLicenseReport = { + outcome: { state: 'check-failed', error: error instanceof Error ? error.message : String(error) }, + } + setReport(report) + return report + } + }, + [device.readLicense, device.refreshLicense, setReport, startCheck, target], + ) + + const check = useCallback(() => run('check'), [run]) + const refresh = useCallback(() => run('refresh'), [run]) + + /** + * Build the purchase link for a given device id. + * + * A purchase must be BOUND to a device, and the id is derived main-side, so + * there is nothing to bind to before a report has landed — hence null. The + * builder also refuses an id the `/buy` page would reject, so a malformed one + * yields no button rather than a dead end. + */ + const urlFor = useCallback( + (deviceId: string | undefined): string | null => { + if (!target.licensable) return null + return buildLicenseBuyUrl({ baseUrl: system.getEdgeFrontendUrl(), vppId: target.packageId, deviceId }) + }, + [system, target], + ) + + /** The link for whatever is in the store — what the badge's button uses. */ + const buyUrl = useMemo(() => urlFor(report?.deviceId), [report?.deviceId, urlFor]) + + const buy = useCallback( + async (deviceId?: string): Promise => { + // Prefer the id the CALLER is holding. See the docstring on `buy` above: + // the dialog opened right after a licensing call has the fresh report, while + // this hook's closure still sees the previous one. + const url = urlFor(deviceId ?? report?.deviceId) + if (!url) return + await system.openExternalLink(url) + }, + [report?.deviceId, system, urlFor], + ) + + return { + isLicensable: target.licensable, + configurationError: + !target.licensable && target.reason === 'no-package-id' + ? 'This board declares a licensed VPP but its package is missing an id. The VPP package needs fixing.' + : null, + report, + isChecking: phase === 'checking', + check, + refresh, + buyUrl, + buy, + } +} diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index cbc0b7843..b0dd3e54f 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -252,6 +252,111 @@ describe('createDeviceSlice', () => { debugTransport: null, }) }) + + it('clearDeviceConnection leaves licensing alone — the link and the entitlement are separate facts', () => { + // A link that drops and comes back does not change what the device is + // entitled to run. Clearing the licence here would make the badge blank on + // every reconnect and force a needless round trip to learn nothing new. + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + store.getState().deviceActions.clearDeviceConnection() + expect(store.getState().deviceLicense.report?.outcome).toEqual({ state: 'licensed', how: 'already-stored' }) + }) + }) + + // ----------------------------------------------------------------------- + // VPP licensing + // ----------------------------------------------------------------------- + describe('device licensing', () => { + const LICENSED = { + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed' as const, how: 'already-stored' as const }, + } + + it('starts idle with nothing known', () => { + // The state every non-licensable board stays in: nothing runs, so nothing is + // known, and the UI shows no licensing affordance at all. + expect(makeStore().getState().deviceLicense).toEqual({ phase: 'idle', report: null }) + }) + + it('startDeviceLicenseCheck marks the call in flight', () => { + const store = makeStore() + store.getState().deviceActions.startDeviceLicenseCheck() + expect(store.getState().deviceLicense).toEqual({ phase: 'checking', report: null }) + }) + + it('startDeviceLicenseCheck KEEPS the last report instead of blanking it', () => { + // Blanking would make the badge flicker Licensed -> nothing -> Licensed on + // every refresh, and a refresh that failed would leave the UI knowing LESS + // than before it asked. `phase` is what says "asking". + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + store.getState().deviceActions.startDeviceLicenseCheck() + expect(store.getState().deviceLicense).toEqual({ phase: 'checking', report: LICENSED }) + }) + + it('setDeviceLicenseReport lands the report and settles the phase', () => { + const store = makeStore() + store.getState().deviceActions.startDeviceLicenseCheck() + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + expect(store.getState().deviceLicense).toEqual({ phase: 'done', report: LICENSED }) + }) + + it('preserves the outcome union verbatim, including the entitlement distinction', () => { + // The whole point of the union surviving to the store: the UI branches on + // `entitlementChecked` to decide between offering a purchase and offering a + // re-check. A store that flattened this to a boolean would lose it. + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: false }, + }) + expect(store.getState().deviceLicense.report?.outcome).toEqual({ + state: 'unlicensed', + entitlementChecked: false, + }) + + store.getState().deviceActions.setDeviceLicenseReport({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: true, backendReason: 'no active subscription' }, + }) + expect(store.getState().deviceLicense.report?.outcome).toEqual({ + state: 'unlicensed', + entitlementChecked: true, + backendReason: 'no active subscription', + }) + }) + + it('keeps a check-failed outcome distinct from unlicensed', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport({ + outcome: { state: 'check-failed', error: 'Activation request failed: 429' }, + }) + expect(store.getState().deviceLicense.report?.outcome).toEqual({ + state: 'check-failed', + error: 'Activation request failed: 429', + }) + expect(store.getState().deviceLicense.report?.deviceId).toBeUndefined() + }) + + it('clearDeviceLicense resets to idle/null', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + store.getState().deviceActions.clearDeviceLicense() + expect(store.getState().deviceLicense).toEqual({ phase: 'idle', report: null }) + }) + + it('clearDeviceDefinitions clears licensing — a new project may select another board', () => { + // A "Licensed" badge carried across a project close would be an assertion + // about hardware that is not even connected. + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + store.getState().deviceActions.clearDeviceDefinitions() + expect(store.getState().deviceLicense).toEqual({ phase: 'idle', report: null }) + }) }) // ----------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index dbad28dc3..8aa250908 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -193,12 +193,16 @@ describe('Device slice types', () => { includeEthercatStatsInPolling: false, }, deviceConnection: { status: 'disconnected', port: null, transport: null, debugTransport: null }, + deviceLicense: { phase: 'idle', report: null }, } expect(state.deviceAvailableOptions).toBeDefined() expect(state.deviceDefinitions).toBeDefined() expect(state.deviceUpdated).toBeDefined() expect(state.runtimeConnection).toBeDefined() expect(state.deviceConnection).toBeDefined() + // Licensing is its own top-level key, not a field on the connection: the + // link being up and the device being entitled change for unrelated reasons. + expect(state.deviceLicense).toBeDefined() }) }) diff --git a/src/frontend/store/slices/device/index.ts b/src/frontend/store/slices/device/index.ts index 89bfbc0aa..ac986a10b 100644 --- a/src/frontend/store/slices/device/index.ts +++ b/src/frontend/store/slices/device/index.ts @@ -5,6 +5,7 @@ export type { DeviceAvailableOptions, DeviceConnection, DeviceConnectionStatus, + DeviceLicenseInfo, DevicePinMapping, DeviceSlice, DeviceState, diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index f5f77384e..9181e24ce 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -67,6 +67,11 @@ const createDeviceSlice: StateCreator = (s debugTransport: null, }, + deviceLicense: { + phase: 'idle', + report: null, + }, + deviceActions: { setAvailableOptions: ({ availableBoards, availableCommunicationPorts }): void => { setState( @@ -116,7 +121,7 @@ const createDeviceSlice: StateCreator = (s }, clearDeviceDefinitions: (): void => { setState( - produce(({ deviceDefinitions, runtimeConnection, deviceConnection }: DeviceSlice) => { + produce(({ deviceDefinitions, runtimeConnection, deviceConnection, deviceLicense }: DeviceSlice) => { deviceDefinitions.configuration = defaultDeviceConfiguration deviceDefinitions.pinMapping = { pinsByBoard: {}, @@ -139,6 +144,12 @@ const createDeviceSlice: StateCreator = (s deviceConnection.port = null deviceConnection.transport = null deviceConnection.debugTransport = null + // Same for licensing, and for a sharper reason: the next project may + // select a different board entirely, and a "Licensed" badge carried over + // from the previous one would be an assertion about hardware that is not + // even connected. + deviceLicense.phase = 'idle' + deviceLicense.report = null }), ) }, @@ -567,6 +578,30 @@ const createDeviceSlice: StateCreator = (s }), ) }, + startDeviceLicenseCheck: (): void => { + setState( + produce(({ deviceLicense }: DeviceSlice) => { + deviceLicense.phase = 'checking' + // `report` is deliberately left alone — see the action's docstring. + }), + ) + }, + setDeviceLicenseReport: (report): void => { + setState( + produce(({ deviceLicense }: DeviceSlice) => { + deviceLicense.phase = 'done' + deviceLicense.report = report + }), + ) + }, + clearDeviceLicense: (): void => { + setState( + produce(({ deviceLicense }: DeviceSlice) => { + deviceLicense.phase = 'idle' + deviceLicense.report = null + }), + ) + }, setVendorScreenData: (persistenceKey, data): void => { setState( produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index 19f5ce22d..4123852ea 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -1,3 +1,4 @@ +import type { DeviceLicenseReport } from '../../../../middleware/shared/ports/device-port' import type { EtherCATRuntimeStatusResponse } from '../../../../middleware/shared/ports/ethercat-types' import type { BoardInfo, @@ -118,6 +119,34 @@ export type DeviceConnection = { debugTransport: DebugMedium | null } +// --------------------------------------------------------------------------- +// VPP licensing +// --------------------------------------------------------------------------- + +/** + * What the UI knows about the connected device's VPP license. + * + * Separate from `deviceConnection` on purpose: that is about whether the LINK is + * up, this is about what the device is entitled to run. They change for unrelated + * reasons — a link can drop and come back without the license changing, and a + * license can be recovered without the link ever moving — and merging them made + * every reader of one depend on the other. + * + * `report` is null until a licensing call has landed, which is also the state for + * every non-licensable board: nothing runs, so nothing is known, and the UI shows + * no licensing affordance at all. + */ +export type DeviceLicenseInfo = { + /** In flight, so the UI can show progress and refuse to start a second one. */ + phase: 'idle' | 'checking' | 'done' + /** + * The last landed report from `readLicense` / `refreshLicense`. Carries the + * outcome union and the derived `deviceId` (which the renderer cannot compute — + * it needs `node:crypto` — and which feeds the copy button and the buy link). + */ + report: DeviceLicenseReport | null +} + // --------------------------------------------------------------------------- // Device state // --------------------------------------------------------------------------- @@ -134,6 +163,7 @@ export type DeviceState = { } runtimeConnection: RuntimeConnection deviceConnection: DeviceConnection + deviceLicense: DeviceLicenseInfo } // --------------------------------------------------------------------------- @@ -212,6 +242,20 @@ export type DeviceActions = { ) => void /** Reset the serial link to disconnected/null. */ clearDeviceConnection: () => void + /** + * Mark a licensing call as in flight. + * + * Deliberately KEEPS the last report rather than clearing it. Blanking it would + * make the badge flicker from "Licensed" to nothing and back on every refresh — + * and worse, a refresh that fails would leave the UI with less information than + * it had before asking. The `phase` is what says "asking"; the report stays as + * the last thing actually known. + */ + startDeviceLicenseCheck: () => void + /** Land a finished licensing call: `phase='done'`, store the report. */ + setDeviceLicenseReport: (report: DeviceLicenseReport) => void + /** Reset licensing to `idle`/null — on disconnect, board change, project close. */ + clearDeviceLicense: () => void setVendorScreenData: (persistenceKey: string, data: unknown) => void /** Restore `vendorScreenData[k]` for every k in `ownedKeys`: from * `snapshot[k]` when present, else by deleting the key. Used by diff --git a/src/frontend/utils/__tests__/license-buy-url.test.ts b/src/frontend/utils/__tests__/license-buy-url.test.ts new file mode 100644 index 000000000..e3cb2a7f0 --- /dev/null +++ b/src/frontend/utils/__tests__/license-buy-url.test.ts @@ -0,0 +1,85 @@ +import { buildLicenseBuyUrl } from '../license-buy-url' + +const DEVICE_ID = '7146518f9842adacfadc731ee7f546e5' +const VPP_ID = 'com.openplc.raspberry-pi-licensed' + +describe('buildLicenseBuyUrl', () => { + it('builds the purchase link the /buy page accepts', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://edge.autonomylogic.com', vppId: VPP_ID, deviceId: DEVICE_ID }) + expect(url).toBe(`https://edge.autonomylogic.com/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`) + }) + + it('points at a local Edge app when the base URL does', () => { + expect(buildLicenseBuyUrl({ baseUrl: 'http://localhost:5173', vppId: VPP_ID, deviceId: DEVICE_ID })).toBe( + `http://localhost:5173/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`, + ) + }) + + it('tolerates a trailing slash on the base URL', () => { + expect( + buildLicenseBuyUrl({ baseUrl: 'https://edge.autonomylogic.com//', vppId: VPP_ID, deviceId: DEVICE_ID }), + ).toBe(`https://edge.autonomylogic.com/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`) + }) + + // An Edge app hosted under a sub-path must keep it: `new URL('/buy', base)` + // would silently drop the prefix and 404. + it('keeps a base path instead of resolving to the origin root', () => { + expect(buildLicenseBuyUrl({ baseUrl: 'https://example.com/edge', vppId: VPP_ID, deviceId: DEVICE_ID })).toBe( + `https://example.com/edge/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`, + ) + }) + + // The web build can be configured with a same-origin prefix + // (`VITE_EDGE_FRONTEND_URL=/`), which is not a parseable absolute URL. + it.each([ + ['/', `/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`], + ['', `/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`], + ['/edge', `/edge/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`], + ])('falls back to a root-relative link for the same-origin prefix %p', (baseUrl, expected) => { + expect(buildLicenseBuyUrl({ baseUrl, vppId: VPP_ID, deviceId: DEVICE_ID })).toBe(expected) + }) + + it('percent-encodes a vppId carrying URL-significant characters', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://e.test', vppId: 'com.a b&c', deviceId: DEVICE_ID }) + expect(url).toBe(`https://e.test/buy?vppId=com.a+b%26c&deviceId=${DEVICE_ID}`) + }) + + // Returning null (instead of a partial link) is the point: the /buy page can + // only answer "Invalid purchase link", so the caller must explain instead. + it.each([ + ['no vppId', { vppId: undefined, deviceId: DEVICE_ID }], + ['blank vppId', { vppId: ' ', deviceId: DEVICE_ID }], + ['no deviceId', { vppId: VPP_ID, deviceId: undefined }], + ['blank deviceId', { vppId: VPP_ID, deviceId: '' }], + ['deviceId too short', { vppId: VPP_ID, deviceId: DEVICE_ID.slice(0, 31) }], + ['deviceId too long', { vppId: VPP_ID, deviceId: `${DEVICE_ID}00` }], + ['deviceId not hex', { vppId: VPP_ID, deviceId: 'z146518f9842adacfadc731ee7f546e5' }], + // The hardware anchor is a different value AND a different length — the + // guard is what keeps a mislabelled field from reaching a purchase. + ['the hardware anchor instead of the deviceId', { vppId: VPP_ID, deviceId: '38363235383037623061383361653764aa' }], + ])('refuses to build a link with %s', (_label, ids) => { + expect(buildLicenseBuyUrl({ baseUrl: 'https://edge.autonomylogic.com', ...ids })).toBeNull() + }) + + it('accepts an uppercase deviceId, matching the page guard', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://e.test', vppId: VPP_ID, deviceId: DEVICE_ID.toUpperCase() }) + expect(url).toBe(`https://e.test/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID.toUpperCase()}`) + }) + + it('trims surrounding whitespace off both ids', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://e.test', vppId: ` ${VPP_ID} `, deviceId: ` ${DEVICE_ID} ` }) + expect(url).toBe(`https://e.test/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`) + }) + + // The link carries EXACTLY the two ids the /buy page reads. It used to also + // carry `devicePublicKey`, because the purchase was the moment that bound a + // proof-of-possession key to a device (ADR-0002). That key is gone: on bare + // metal the anchor is read inside the closed license-core and the blob is bound + // to `deviceId`, so possession is proven by the silicon, not by a signature. + // Asserted as a WHOLE-STRING equality on purpose — a `toContain` would let a + // third parameter creep back in without failing. + it('carries no query parameter beyond the two ids', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://e.test', vppId: VPP_ID, deviceId: DEVICE_ID }) + expect(url).toBe(`https://e.test/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`) + }) +}) diff --git a/src/frontend/utils/__tests__/license-outcome-dialog.test.ts b/src/frontend/utils/__tests__/license-outcome-dialog.test.ts new file mode 100644 index 000000000..c426193d8 --- /dev/null +++ b/src/frontend/utils/__tests__/license-outcome-dialog.test.ts @@ -0,0 +1,192 @@ +import type { DeviceLicenseReport } from '@root/middleware/shared/ports/device-port' + +import { explainLicenseOutcome, type OpenLicenseDialog } from '../license-outcome-dialog' + +type DialogProps = Parameters[1] + +function harness() { + const opened: DialogProps[] = [] + const openModal: OpenLicenseDialog = (_name, props) => { + opened.push(props) + } + const buy = jest.fn(() => Promise.resolve()) + const retry = jest.fn(() => Promise.resolve()) + return { opened, openModal, buy, retry } +} + +function report(outcome: DeviceLicenseReport['outcome']): DeviceLicenseReport { + return { deviceId: '659a3520540f803625ddc34081e893d3', outcome } +} + +describe('explainLicenseOutcome', () => { + it('says NOTHING when the device is licensed', () => { + // A dialog confirming a licence nobody asked about would fire on every single + // connect to a licensed board. + const { opened, openModal, buy, retry } = harness() + + const shown = explainLicenseOutcome(report({ state: 'licensed', how: 'already-stored' }), { + openModal, + buy, + retry, + }) + + expect(shown).toBe(false) + expect(opened).toHaveLength(0) + }) + + it('is silent for a freshly activated licence too', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'licensed', how: 'activated' }), { openModal, buy }) + + expect(opened).toHaveLength(0) + }) + + describe('unlicensed with the entitlement CHECKED', () => { + it('offers to buy, and buying is what the first button does', () => { + const { opened, openModal, buy, retry } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: true }), { openModal, buy, retry }) + + expect(opened).toHaveLength(1) + expect(opened[0].buttons).toEqual(['Buy Licence', 'Continue in Demo Mode']) + opened[0].onResponse(0) + expect(buy).toHaveBeenCalledTimes(1) + expect(retry).not.toHaveBeenCalled() + }) + + it('does nothing when the user chooses demo mode', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: true }), { openModal, buy }) + opened[0].onResponse(1) + + expect(buy).not.toHaveBeenCalled() + }) + + it("quotes the backend's own wording when it gave one", () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome( + report({ state: 'unlicensed', entitlementChecked: true, backendReason: 'no active subscription' }), + { openModal, buy }, + ) + + expect(opened[0].message).toContain('no active subscription') + }) + }) + + describe('unlicensed with the entitlement NOT checked', () => { + it('offers a re-check and NOT a purchase', () => { + // Nobody asked whether a purchase exists. Offering to buy here is the worst + // thing this flow can do to someone who already paid. + const { opened, openModal, buy, retry } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: false }), { openModal, buy, retry }) + + expect(opened[0].buttons).toEqual(['Check For Licence', 'Continue in Demo Mode']) + expect(opened[0].buttons).not.toContain('Buy Licence') + opened[0].onResponse(0) + expect(retry).toHaveBeenCalledTimes(1) + expect(buy).not.toHaveBeenCalled() + }) + + it('degrades to a plain acknowledgement when no retry is available', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: false }), { openModal, buy }) + + expect(opened[0].buttons).toEqual(['OK']) + // Pressing the only button must not silently trigger anything. + opened[0].onResponse(0) + expect(buy).not.toHaveBeenCalled() + }) + }) + + describe('unsupported', () => { + it('never offers a purchase — buying would not make the device able to store one', () => { + const { opened, openModal, buy, retry } = harness() + + explainLicenseOutcome(report({ state: 'unsupported' }), { openModal, buy, retry }) + + expect(opened[0].buttons).toEqual(['OK']) + opened[0].onResponse(0) + expect(buy).not.toHaveBeenCalled() + expect(retry).not.toHaveBeenCalled() + }) + + it('names the firmware and tells the user to rebuild, without blaming the hardware', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'unsupported' }), { openModal, buy }) + + expect(opened[0].message).toMatch(/rebuild and\s+upload/i) + expect(opened[0].message).toContain('This hardware supports it') + // The old wording ("This Device Cannot Store A Licence") read as a hardware + // limitation and cost a debugging session. + expect(opened[0].title).not.toMatch(/cannot store/i) + }) + }) + + describe('check-failed', () => { + it('says we could not tell, states it is NOT the same as unlicensed, and offers a retry', () => { + const { opened, openModal, buy, retry } = harness() + + explainLicenseOutcome(report({ state: 'check-failed', error: 'Activation request failed: 429' }), { + openModal, + buy, + retry, + }) + + expect(opened[0].type).toBe('error') + expect(opened[0].message).toContain('Activation request failed: 429') + expect(opened[0].message).toContain('NOT the same as having no licence') + expect(opened[0].buttons).toEqual(['Try Again', 'Continue']) + opened[0].onResponse(0) + expect(retry).toHaveBeenCalledTimes(1) + // Never a purchase: we do not know that a purchase is what is missing. + expect(buy).not.toHaveBeenCalled() + }) + + it('never labels the failure as "not licensed"', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'check-failed', error: 'Request timeout' }), { openModal, buy }) + + expect(opened[0].title).not.toMatch(/not licen/i) + expect(opened[0].buttons).not.toContain('Buy Licence') + }) + }) + + describe('demo-mode wording', () => { + it('is explicit that the DEVICE enforces demo mode, not the editor', () => { + // Saying or implying the editor gates the upload would be false: it uploads + // either way, and the licence-core on the device is what limits actuation. + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: true }), { openModal, buy }) + + expect(opened[0].message).toContain('enforced on the device, not by the editor') + expect(opened[0].message).toContain('build and upload') + }) + + it('never claims to know the execution mode the board is running in', () => { + // The editor verifies POSSESSION, not the signature — it cannot know whether + // the closed core will run FULL. No branch may assert an execution mode. + const { opened, openModal, buy, retry } = harness() + const outcomes: DeviceLicenseReport['outcome'][] = [ + { state: 'unlicensed', entitlementChecked: true }, + { state: 'unlicensed', entitlementChecked: false }, + { state: 'unsupported' }, + { state: 'check-failed', error: 'x' }, + ] + + for (const outcome of outcomes) explainLicenseOutcome(report(outcome), { openModal, buy, retry }) + + for (const props of opened) { + expect(props.title).not.toMatch(/full mode|unlocked/i) + expect(props.message).not.toMatch(/full mode|unlocked/i) + } + }) + }) +}) diff --git a/src/frontend/utils/license-buy-url.ts b/src/frontend/utils/license-buy-url.ts new file mode 100644 index 000000000..83d2fa203 --- /dev/null +++ b/src/frontend/utils/license-buy-url.ts @@ -0,0 +1,57 @@ +/** + * Buy-license deep link (D68a) — carries the device INTO the Edge purchase page. + * + * The page is `/buy` on the Edge **web app** (not the API host), and it needs + * both ids up front: it validates `deviceId` against `/^[0-9a-fA-F]{32}$/` and + * resolves the VPP through `GET vpp-catalog/v1/vpps/:vppId`. That `vppId` is the + * **reverse-domain package id** (`package.id`, e.g. + * `com.openplc.raspberry-pi-licensed`) — NOT a database id — which is exactly + * what the editor already holds as `boardInfo.vpp.packageId`, so no lookup is + * needed to build the link. + * + * A link missing either id lands the buyer on "Invalid purchase link" with no + * way forward, so an incomplete input resolves to `null` here and the caller + * says something useful instead of opening a dead end. + */ + +export interface LicenseBuyLinkInput { + /** Absolute base URL of the Edge web app, or a same-origin prefix on web. */ + baseUrl: string + /** Reverse-domain package id of the licensable VPP (`package.id`). */ + vppId: string | undefined + /** The derived licensing identity (32 hex chars), NOT the hardware anchor. */ + deviceId: string | undefined +} + +/** Mirrors the `/buy` page's own guard — reject before navigating, not after. */ +const DEVICE_ID_RE = /^[0-9a-f]{32}$/i + +function withoutTrailingSlash(base: string): string { + return base.replace(/\/+$/, '') +} + +/** + * Build the `/buy?vppId=…&deviceId=…` URL, or `null` when the ids can't produce + * a link the purchase page will accept. + */ +export function buildLicenseBuyUrl({ baseUrl, vppId, deviceId }: LicenseBuyLinkInput): string | null { + const vpp = vppId?.trim() + const device = deviceId?.trim() + if (!vpp || !device || !DEVICE_ID_RE.test(device)) return null + + const query = new URLSearchParams({ vppId: vpp, deviceId: device }).toString() + const path = `${withoutTrailingSlash(baseUrl)}/buy` + + try { + // Single-argument form on purpose: it preserves a base path, so an Edge app + // hosted under a sub-path still gets `/prefix/buy` instead of `/buy`. + const url = new URL(path) + url.search = query + return url.toString() + } catch { + // Not absolute — the web build can be configured with a same-origin prefix + // (`VITE_EDGE_FRONTEND_URL=/`). A root-relative link is correct there; the + // editor never reaches this branch (its adapter always yields an origin). + return `${path}?${query}` + } +} diff --git a/src/frontend/utils/license-outcome-dialog.ts b/src/frontend/utils/license-outcome-dialog.ts new file mode 100644 index 000000000..4e46fd2df --- /dev/null +++ b/src/frontend/utils/license-outcome-dialog.ts @@ -0,0 +1,137 @@ +/** + * Turn a licensing outcome into what the user is told — and what they are offered. + * + * Pure decision, separate from the hook that calls it, because this is where the + * union's distinctions become promises to a customer and each branch has to be + * defensible on its own: + * + * licensed → say nothing. A silent success is the correct UX; a dialog + * confirming a licence nobody asked about is noise on every + * single connect. + * unlicensed + checked → demo mode, and OFFER TO BUY. The backend was asked + * and said there is no purchase, so this is the one branch + * where pointing at a purchase page is honest. + * unlicensed + NOT checked → demo mode, but offer to CHECK AGAIN, not to buy. + * Nobody asked whether a purchase exists; telling someone + * who already paid to pay again is the worst thing this + * flow can do. + * unsupported → the device cannot store a licence. Buying would not help, + * so no purchase is offered; the detail (when present) names + * a build mismatch, which is actionable. + * check-failed → say we could not tell, and offer a retry. Never present + * this as "not licensed". + * + * The demo-mode wording is deliberate about WHO enforces it: the closed + * license-core inside the VPP does, on the device. The editor uploads either way — + * it does not gate the build on a licence, and saying it might would be false. + */ + +import type { DeviceLicenseReport, DeviceLicenseState } from '@root/middleware/shared/ports/device-port' + +/** The subset of the modal action this needs, so tests need no store. */ +export type OpenLicenseDialog = ( + name: 'debugger-message', + props: { + type: 'error' | 'warning' | 'question' | 'info' + title: string + message: string + buttons: string[] + onResponse: (buttonIndex: number) => void + }, +) => void + +export interface LicenseDialogHandlers { + openModal: OpenLicenseDialog + /** + * Open the device-bound purchase page for `deviceId`. + * + * The id is passed IN rather than looked up by the handler, because this dialog + * is opened straight out of a licensing call and the handler's own view of the + * current report is one render behind — so a lookup would find nothing on a + * first connect and the button would do nothing. + */ + buy: (deviceId?: string) => Promise + /** Re-run the full licensing flow (offered on a failed or unchecked outcome). */ + retry?: () => Promise +} + +const DEMO_EXPLANATION = + 'The device will run in DEMO mode: the VPP stops driving outputs a few minutes after each start. ' + + 'You can still build and upload — the licence is enforced on the device, not by the editor.' + +/** + * Show the dialog this outcome warrants, if any. Returns whether one was opened, + * which is what makes "licensed is silent" testable rather than assumed. + */ +export function explainLicenseOutcome(report: DeviceLicenseReport, handlers: LicenseDialogHandlers): boolean { + const { openModal, buy, retry } = handlers + const outcome: DeviceLicenseState = report.outcome + + switch (outcome.state) { + case 'licensed': + // Silence on purpose. See the module docstring. + return false + + case 'unlicensed': { + if (outcome.entitlementChecked) { + const reason = outcome.backendReason ? `\n\nThe licence server said: ${outcome.backendReason}` : '' + openModal('debugger-message', { + type: 'warning', + title: 'No Licence For This Device', + message: `This VPP is a paid product and no licence is registered for this device.${reason}\n\n${DEMO_EXPLANATION}`, + buttons: ['Buy Licence', 'Continue in Demo Mode'], + onResponse: (buttonIndex: number) => { + if (buttonIndex === 0) void buy(report.deviceId) + }, + }) + return true + } + + // Nobody asked the backend. Offer a check, NOT a purchase. + openModal('debugger-message', { + type: 'warning', + title: 'No Licence Stored On This Device', + message: `This device is not holding a valid licence for this VPP. It may simply not have been activated yet.\n\n${DEMO_EXPLANATION}`, + buttons: retry ? ['Check For Licence', 'Continue in Demo Mode'] : ['OK'], + onResponse: (buttonIndex: number) => { + if (retry && buttonIndex === 0) void retry() + }, + }) + return true + } + + case 'unsupported': + // Unconditional wording, and it names the FIRMWARE. Every licensable VPP + // targets hardware that persists a licence, so a board answering this was + // built without its storage backend — "your hardware cannot do this" would + // be false, and it is the message that sent us looking at a NodeMCU that + // stores licences perfectly well. + openModal('debugger-message', { + type: 'warning', + title: 'Licence Storage Missing From This Firmware', + message: + 'The firmware running on this device reports no licence storage, so a licence cannot be ' + + 'written to it.\n\nThis hardware supports it — every licensed VPP targets hardware that ' + + 'does. The image on the board was built without the storage backend, so rebuild and ' + + `upload it.\n\n${DEMO_EXPLANATION}`, + // No purchase offered: buying would not fix a firmware built wrong. + buttons: ['OK'], + onResponse: () => undefined, + }) + return true + + case 'check-failed': + openModal('debugger-message', { + type: 'error', + title: 'Licence Check Failed', + message: + `The editor could not determine whether this device holds a licence.\n\n${outcome.error}\n\n` + + 'This is NOT the same as having no licence — nothing has changed on the device.', + buttons: retry ? ['Try Again', 'Continue'] : ['OK'], + onResponse: (buttonIndex: number) => { + if (retry && buttonIndex === 0) void retry() + }, + }) + return true + } +} diff --git a/src/main/modules/ipc/__tests__/device-license.handler.test.ts b/src/main/modules/ipc/__tests__/device-license.handler.test.ts new file mode 100644 index 000000000..93e1052a3 --- /dev/null +++ b/src/main/modules/ipc/__tests__/device-license.handler.test.ts @@ -0,0 +1,263 @@ +/** + * The two licensing IPC handlers, over a fake held link. + * + * What is worth testing HERE (as opposed to in `license-flow`, which owns the + * decision logic) is the wiring: that the handlers take the CONTROL channel + * rather than opening a connection, that a missing link is reported as + * `check-failed` and never as "not licensed", that the anchor is read fresh, that + * the compound sequence is guarded, and that device traffic is noted so the + * liveness poll does not declare a healthy link lost mid-sequence. + */ + +import MainProcessBridge from '../main' + +jest.mock('electron', () => ({ + app: { getPath: jest.fn(() => '/tmp') }, + dialog: {}, + nativeTheme: { shouldUseDarkColors: false, themeSource: 'system' }, + shell: { openExternal: jest.fn() }, +})) + +jest.mock('@root/backend/editor/ethercat', () => ({ ESIService: jest.fn() })) +jest.mock('@root/backend/editor/library-manager/desktop-catalog-transport', () => ({ + createDesktopCatalogTransport: jest.fn(() => ({})), +})) +jest.mock('@root/backend/editor/utils/runtime-https-config', () => ({ getRuntimeHttpsOptions: jest.fn(() => ({})) })) +jest.mock('@root/backend/shared/ethercat/esi-parser-main', () => ({ parseESIDeviceFull: jest.fn() })) +jest.mock('@root/backend/shared/library/public-catalog-client', () => ({ listPublicLibraries: jest.fn() })) +jest.mock('../../../../backend/editor/library-manager', () => ({ + LibraryManagerModule: jest.fn(() => ({ loadEnabledArchives: jest.fn(() => ({ archives: [], missing: [] })) })), +})) +jest.mock('../../../../backend/editor/package-manager', () => ({ PackageManagerModule: jest.fn(() => ({})) })) +jest.mock('../../../../backend/editor/services', () => ({ + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, +})) +jest.mock('../../../../backend/editor/utils', () => ({ getOpenProjectPath: jest.fn(), getProjectPath: jest.fn() })) +jest.mock('../../../../backend/shared/simulator/simulator-module', () => ({ + SimulatorModule: jest.fn(() => ({ stop: jest.fn() })), +})) + +jest.mock('../../../../backend/editor/license/license-flow', () => ({ + inspectDeviceLicense: jest.fn(), + resolveDeviceLicense: jest.fn(), +})) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const licenseFlow = require('../../../../backend/editor/license/license-flow') as { + inspectDeviceLicense: jest.Mock + resolveDeviceLicense: jest.Mock +} + +const REQUEST = { packageId: 'com.openplc.espressif-licensed' } +const ANCHOR = Uint8Array.from([0, 177, 140, 237]) + +function createBridge() { + return new MainProcessBridge({ + ipcMain: {}, + mainWindow: { isDestroyed: jest.fn(() => false), isMaximized: jest.fn(() => false) }, + projectService: {}, + store: { get: jest.fn(() => undefined) }, + menuBuilder: {}, + pouService: {}, + compilerModule: {}, + hardwareModule: { isSerialPortPresent: jest.fn(() => true) }, + } as never) +} + +/** Install a fake held CONTROL client on the bridge's session manager. */ +function holdClient( + bridge: ReturnType, + client: { getBoardId: jest.Mock; readLicense?: jest.Mock; writeLicense?: jest.Mock }, +) { + const session = (bridge as unknown as { deviceSession: { getClient: () => unknown; noteTraffic: () => void } }) + .deviceSession + jest.spyOn(session, 'getClient').mockReturnValue(client) + return jest.spyOn(session, 'noteTraffic') +} + +function boardIdClient(overrides: Partial<{ getBoardId: jest.Mock }> = {}) { + return { + getBoardId: jest.fn(() => Promise.resolve({ success: true, boardId: ANCHOR })), + readLicense: jest.fn(), + writeLicense: jest.fn(), + ...overrides, + } +} + +beforeEach(() => { + licenseFlow.inspectDeviceLicense.mockReset() + licenseFlow.resolveDeviceLicense.mockReset() +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('device:read-license', () => { + it('reads the anchor off the held link and hands it to the inspect flow', async () => { + const bridge = createBridge() + const client = boardIdClient() + const noteTraffic = holdClient(bridge, client) + licenseFlow.inspectDeviceLicense.mockResolvedValue({ + deviceId: 'abc', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + + const result = await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(client.getBoardId).toHaveBeenCalledTimes(1) + expect(licenseFlow.inspectDeviceLicense).toHaveBeenCalledWith(client, { ...REQUEST, anchor: ANCHOR }) + expect(result).toEqual({ deviceId: 'abc', outcome: { state: 'licensed', how: 'already-stored' } }) + // The board-id read is device traffic; not noting it lets the liveness poll + // fall due behind it and declare a healthy link lost. + expect(noteTraffic).toHaveBeenCalled() + }) + + it('reports check-failed — never "not licensed" — when no link is held', async () => { + const bridge = createBridge() + // No client installed: getClient() returns null. + + const result = await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(result.outcome.state).toBe('check-failed') + expect(licenseFlow.inspectDeviceLicense).not.toHaveBeenCalled() + }) + + it('reports check-failed when the device will not answer the board-id read', async () => { + const bridge = createBridge() + const client = boardIdClient({ + getBoardId: jest.fn(() => Promise.resolve({ success: false, error: 'Request timeout' })), + }) + holdClient(bridge, client) + + const result = await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'Request timeout' }) + expect(licenseFlow.inspectDeviceLicense).not.toHaveBeenCalled() + }) + + it('passes an empty anchor through rather than inventing one', async () => { + // A board answering id_len = 0 is a real reply; the flow is the layer that + // knows it cannot be licensed, and it must get the chance to say so. + const bridge = createBridge() + const client = boardIdClient({ getBoardId: jest.fn(() => Promise.resolve({ success: true })) }) + holdClient(bridge, client) + licenseFlow.inspectDeviceLicense.mockResolvedValue({ outcome: { state: 'check-failed', error: 'no unique id' } }) + + await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(licenseFlow.inspectDeviceLicense).toHaveBeenCalledWith(client, { + ...REQUEST, + anchor: new Uint8Array(0), + }) + }) + + it('turns an unexpected throw into check-failed instead of rejecting the IPC call', async () => { + const bridge = createBridge() + holdClient(bridge, boardIdClient()) + licenseFlow.inspectDeviceLicense.mockRejectedValue(new Error('port closed')) + + const result = await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'port closed' }) + }) +}) + +describe('device:refresh-license', () => { + it('runs the full flow over the held link and notes the traffic', async () => { + const bridge = createBridge() + const client = boardIdClient() + const noteTraffic = holdClient(bridge, client) + licenseFlow.resolveDeviceLicense.mockResolvedValue({ + deviceId: 'abc', + outcome: { state: 'licensed', how: 'activated' }, + }) + + const result = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(licenseFlow.resolveDeviceLicense).toHaveBeenCalledWith(client, { ...REQUEST, anchor: ANCHOR }) + expect(result.outcome).toEqual({ state: 'licensed', how: 'activated' }) + // Once for the anchor read, once after the sequence: the sequence spans an + // HTTP round trip, which is long enough for the poll to fall due inside it. + expect(noteTraffic.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + it('refuses a second concurrent sequence rather than interleaving it', async () => { + // The frame mutex cannot see a SEQUENCE: two refreshes would each send atomic + // frames while interleaving a read and a write of the same license, one + // reading back the other's blob and drawing a conclusion about it. + const bridge = createBridge() + holdClient(bridge, boardIdClient()) + + let release: (() => void) | undefined + let entered = false + licenseFlow.resolveDeviceLicense.mockImplementation(() => { + entered = true + return new Promise((resolve) => { + release = () => resolve({ deviceId: 'abc', outcome: { state: 'licensed', how: 'activated' } }) + }) + }) + + const first = bridge.handleDeviceRefreshLicense({} as never, REQUEST) + // Let the first sequence get past its anchor read and INTO the flow before + // racing it; asserting on a call that has not been reached yet would test the + // scheduler rather than the guard. + while (!entered) await new Promise((resolve) => setTimeout(resolve, 0)) + + const second = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(second.outcome).toEqual({ + state: 'check-failed', + error: 'A license check is already running on this device.', + }) + expect(licenseFlow.resolveDeviceLicense).toHaveBeenCalledTimes(1) + + release?.() + await expect(first).resolves.toMatchObject({ outcome: { state: 'licensed' } }) + }) + + it('clears the sequence guard after a failure, so a retry is possible', async () => { + const bridge = createBridge() + holdClient(bridge, boardIdClient()) + licenseFlow.resolveDeviceLicense.mockRejectedValueOnce(new Error('port closed')) + + const failed = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + expect(failed.outcome).toEqual({ state: 'check-failed', error: 'port closed' }) + + // A guard left set by a throw would strand the device: every later attempt + // would answer "already running" with nothing actually running. + licenseFlow.resolveDeviceLicense.mockResolvedValue({ + deviceId: 'abc', + outcome: { state: 'unlicensed', entitlementChecked: true }, + }) + const retried = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(retried.outcome).toEqual({ state: 'unlicensed', entitlementChecked: true }) + }) + + it('reports check-failed when no link is held', async () => { + const bridge = createBridge() + + const result = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(result.outcome.state).toBe('check-failed') + expect(licenseFlow.resolveDeviceLicense).not.toHaveBeenCalled() + }) + + it('does not consume the sequence guard when there is no link to run on', async () => { + const bridge = createBridge() + + await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + // The guard is taken AFTER the channel check, so a disconnected device does + // not leave it set for the next attempt. + holdClient(bridge, boardIdClient()) + licenseFlow.resolveDeviceLicense.mockResolvedValue({ + deviceId: 'abc', + outcome: { state: 'licensed', how: 'activated' }, + }) + const result = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(result.outcome).toEqual({ state: 'licensed', how: 'activated' }) + }) +}) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 30afb8761..387d3c5ff 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -13,6 +13,7 @@ import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { DeviceLicenseReport, DeviceLicenseRequest } from '@root/middleware/shared/ports/device-port' import type { EtherCATRuntimeStatusResponse, EtherCATScanRequest, @@ -66,6 +67,7 @@ import { modbusTransportKind, } from '../../../backend/editor/hardware/device-transport-factory' import { LibraryManagerModule } from '../../../backend/editor/library-manager' +import { inspectDeviceLicense, resolveDeviceLicense } from '../../../backend/editor/license/license-flow' import { PackageManagerModule } from '../../../backend/editor/package-manager' import { logger } from '../../../backend/editor/services' import { @@ -1081,6 +1083,10 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('device:connect', this.handleDeviceConnect) this.registerHandle('device:disconnect', this.handleDeviceDisconnect) this.registerHandle('device:release-serial-port', this.handleDeviceReleaseSerialPort) + // VPP licensing over the HELD link — callable any time the device is + // connected, deliberately not folded into `device:connect`. See the handlers. + this.registerHandle('device:read-license', this.handleDeviceReadLicense) + this.registerHandle('device:refresh-license', this.handleDeviceRefreshLicense) this.registerHandle('session:open-runtime', this.handleOpenRuntimeSession) this.registerHandle('session:close-runtime', this.handleCloseRuntimeSession) @@ -2408,6 +2414,112 @@ class MainProcessBridge implements MainIpcModule { return { success: true } } + // ===================== VPP LICENSING OVER THE HELD LINK ===================== + // + // WHY THESE ARE ON-DEMAND, AND NOT PART OF `device:connect`. + // + // Two reasons, both learned the hard way. First, `refreshLicense` reaches the + // NETWORK: folding it into connect makes every connect to a licensed board wait + // on an HTTP round trip that may be rate-limited or time out, and a connect that + // hangs looks like a broken cable. Second, a purchase happens AFTER the user has + // already been told they are in demo mode — with licensing bolted to connect, the + // only way to pick up a licence they just bought is to disconnect and reconnect, + // or reflash. Exposing the step means the buy dialog can simply call it again on + // the link that is already open. + // + // Both ride the CONTROL channel (`requireControl`): the license FCs are ordinary + // frames on the same held Modbus link as run/stop and the status poll, so they + // queue behind the same mutex and need no connection of their own. + + /** + * True while a COMPOUND licensing sequence is running over the held client. + * + * The transports already serialise individual FRAMES, and that is not enough + * here. `refreshLicense` is read -> HTTP -> write -> read, and two of them + * running at once would each see perfectly atomic frames while interleaving a + * read and a write of the SAME license — one sequence reading back the other's + * blob and drawing a conclusion about it. The frame mutex cannot see the + * sequence, so the sequence needs its own guard. + */ + private deviceLicenseSequenceInFlight = false + + /** + * Read the anchor the licensing identity derives from, over the held link. + * + * Read FRESH on every licensing call rather than cached at connect. The anchor + * IS the device identity, and a board swapped on the same serial path would + * otherwise inherit the previous one's — deciding a license question for + * hardware that is no longer there. One extra frame on an operation that already + * spends several is not worth that risk. + */ + private async readLicenseAnchor(client: DeviceModbusTransport): Promise<{ anchor: Uint8Array } | { error: string }> { + const board = await client.getBoardId() + if (!board.success) return { error: board.error ?? 'the device did not answer the board-id read' } + this.deviceSession.noteTraffic() + return { anchor: board.boardId ?? new Uint8Array(0) } + } + + /** + * `device:read-license` — what license is this board holding right now? + * + * Read + verify only; never contacts the backend, so it is cheap enough for a + * screen open. It answers the question the badge asks, which `device:connect` + * deliberately does not. + */ + handleDeviceReadLicense = async ( + _event: IpcMainInvokeEvent, + request: DeviceLicenseRequest, + ): Promise => { + const control = this.requireControl('read license') + if ('error' in control) return { outcome: { state: 'check-failed', error: control.error } } + + try { + const anchor = await this.readLicenseAnchor(control.client) + if ('error' in anchor) return { outcome: { state: 'check-failed', error: anchor.error } } + + return await inspectDeviceLicense(control.client, { ...request, anchor: anchor.anchor }) + } catch (error) { + return { outcome: { state: 'check-failed', error: getErrorMessage(error) } } + } + } + + /** + * `device:refresh-license` — the full flow, including the backend recovery. + * + * Called after a connect to a licensable board, and again after a purchase so a + * device gets its license without disconnecting or reflashing. + */ + handleDeviceRefreshLicense = async ( + _event: IpcMainInvokeEvent, + request: DeviceLicenseRequest, + ): Promise => { + const control = this.requireControl('refresh license') + if ('error' in control) return { outcome: { state: 'check-failed', error: control.error } } + + if (this.deviceLicenseSequenceInFlight) { + // Reported rather than queued: the caller is a button or a connect, and a + // second answer arriving later for a question already being answered is + // noise at best and a contradictory badge at worst. + return { outcome: { state: 'check-failed', error: 'A license check is already running on this device.' } } + } + this.deviceLicenseSequenceInFlight = true + + try { + const anchor = await this.readLicenseAnchor(control.client) + if ('error' in anchor) return { outcome: { state: 'check-failed', error: anchor.error } } + + const report = await resolveDeviceLicense(control.client, { ...request, anchor: anchor.anchor }) + // The license FCs are device traffic like any other: without noting them the + // liveness poll can fall due mid-sequence and declare a healthy link lost. + this.deviceSession.noteTraffic() + return report + } catch (error) { + return { outcome: { state: 'check-failed', error: getErrorMessage(error) } } + } finally { + this.deviceLicenseSequenceInFlight = false + } + } + handleDebuggerSetVariable = async ( _event: IpcMainInvokeEvent, variableIndex: number, diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 120ff0ccc..a159ab263 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,5 +1,9 @@ import type { DiscoveredRuntimeDevice, RuntimeLogEntry } from '@root/middleware/shared/ports' -import type { DeviceConnectionStatusPayload } from '@root/middleware/shared/ports/device-port' +import type { + DeviceConnectionStatusPayload, + DeviceLicenseReport, + DeviceLicenseRequest, +} from '@root/middleware/shared/ports/device-port' import type { ESIDevice, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import type { EtherCATRuntimeStatusResponse, @@ -476,6 +480,15 @@ const rendererProcessBridge = { deviceReleaseSerialPort: (port: string | null | undefined): Promise<{ released: boolean }> => ipcRenderer.invoke('device:release-serial-port', port), + // VPP licensing over the HELD link. `read` is local-only (read + verify) and + // cheap; `refresh` may reach the network and write, which is why they are + // separate channels and neither is part of `device:connect`. + deviceReadLicense: (request: DeviceLicenseRequest): Promise => + ipcRenderer.invoke('device:read-license', request), + + deviceRefreshLicense: (request: DeviceLicenseRequest): Promise => + ipcRenderer.invoke('device:refresh-license', request), + // Diagnostic trace of the device connection (candidate attempts, poll verdicts, // which connection served each command), mirrored into the editor console so it // can be read and copied while reproducing a problem. diff --git a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts index def534176..19d4ea42f 100644 --- a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts @@ -32,6 +32,14 @@ beforeEach(() => { openRuntimeSession: jest.fn().mockResolvedValue({ success: true }), closeRuntimeSession: jest.fn().mockResolvedValue({ success: true }), deviceReleaseSerialPort: jest.fn().mockResolvedValue({ released: true }), + deviceReadLicense: jest.fn().mockResolvedValue({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }), + deviceRefreshLicense: jest.fn().mockResolvedValue({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: true }, + }), onDeviceLinkLog: jest.fn().mockReturnValue(() => undefined), onDevicePlcState: jest.fn().mockReturnValue(() => undefined), } as unknown as typeof window.bridge @@ -151,4 +159,27 @@ describe('createEditorDeviceAdapter', () => { expect(window.bridge.onDevicePlcState).toHaveBeenCalledWith(cb) expect(typeof unsub).toBe('function') }) + + it('delegates readLicense to the local-only license channel', async () => { + const request = { packageId: 'com.openplc.espressif-licensed' } + + await expect(adapter.readLicense?.(request)).resolves.toEqual({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + expect(window.bridge.deviceReadLicense).toHaveBeenCalledWith(request) + // The cheap channel must not be the one that reaches the network. + expect(window.bridge.deviceRefreshLicense).not.toHaveBeenCalled() + }) + + it('delegates refreshLicense to the recovering license channel', async () => { + const request = { packageId: 'com.openplc.espressif-licensed' } + + await expect(adapter.refreshLicense?.(request)).resolves.toEqual({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: true }, + }) + expect(window.bridge.deviceRefreshLicense).toHaveBeenCalledWith(request) + expect(window.bridge.deviceReadLicense).not.toHaveBeenCalled() + }) }) diff --git a/src/middleware/adapters/editor/__tests__/system-adapter.test.ts b/src/middleware/adapters/editor/__tests__/system-adapter.test.ts index d24aa126b..488e9dfa0 100644 --- a/src/middleware/adapters/editor/__tests__/system-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/system-adapter.test.ts @@ -69,3 +69,18 @@ describe('log', () => { expect(window.bridge.log).toHaveBeenCalledWith('error', 'error message') }) }) + +describe('getEdgeFrontendUrl', () => { + it('resolves an absolute Edge WEB app URL, distinct from the API host', () => { + // The `/buy` page lives on the web app, not the API. Deriving one host from + // the other by string surgery breaks the moment either moves, so this must be + // its own value — and it must be absolute, or `buildLicenseBuyUrl` falls back + // to a root-relative link that Electron cannot open. + const url = adapter.getEdgeFrontendUrl() + + expect(url).toMatch(/^https?:\/\//) + expect(url).not.toContain('api.autonomylogic.com') + // No trailing slash: the URL builder appends `/buy` itself. + expect(url.endsWith('/')).toBe(false) + }) +}) diff --git a/src/middleware/adapters/editor/device-adapter.ts b/src/middleware/adapters/editor/device-adapter.ts index 85185b214..4e30da525 100644 --- a/src/middleware/adapters/editor/device-adapter.ts +++ b/src/middleware/adapters/editor/device-adapter.ts @@ -12,9 +12,17 @@ * hardware:refresh-available-boards (invoke) * hardware:refresh-communication-ports (invoke) * util:get-preview-image (invoke) + * device:read-license (invoke) + * device:refresh-license (invoke) */ -import type { DeviceConnectionStatusPayload, DeviceConnectResult, DevicePort } from '../../shared/ports/device-port' +import type { + DeviceConnectionStatusPayload, + DeviceConnectResult, + DeviceLicenseReport, + DeviceLicenseRequest, + DevicePort, +} from '../../shared/ports/device-port' import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from '../../shared/ports/types' export function createEditorDeviceAdapter(): DevicePort { @@ -63,6 +71,14 @@ export function createEditorDeviceAdapter(): DevicePort { return window.bridge.deviceDisconnect() }, + readLicense(request: DeviceLicenseRequest): Promise { + return window.bridge.deviceReadLicense(request) + }, + + refreshLicense(request: DeviceLicenseRequest): Promise { + return window.bridge.deviceRefreshLicense(request) + }, + onLinkLog(callback: (message: string) => void): () => void { return window.bridge.onDeviceLinkLog(callback) }, diff --git a/src/middleware/adapters/editor/system-adapter.ts b/src/middleware/adapters/editor/system-adapter.ts index 03188e578..e58bd3bbb 100644 --- a/src/middleware/adapters/editor/system-adapter.ts +++ b/src/middleware/adapters/editor/system-adapter.ts @@ -16,6 +16,25 @@ import type { SystemPort } from '../../shared/ports/system-port' import type { SystemInfo } from '../../shared/ports/types' +/** + * Edge web app host — where the license `/buy` page lives. Authoritative for + * shipped builds. + */ +const PRODUCTION_EDGE_WEB_URL = 'https://edge.autonomylogic.com' + +/** + * Build-time override, injected by webpack's `EnvironmentPlugin` (renderer dev + + * prod configs) exactly like `VPP_CATALOG_URL`. Electron renderer bundles have no + * live `process.env`, so this is evaluated when the bundle is BUILT: set it in the + * shell before `npm run dev` to aim the buy flow at a local Edge app. + * + * OPENPLC_EDGE_WEB_URL=http://localhost:5173 npm run dev + * + * The release pipeline does not set it, and webpack's empty-string default is + * falsy, so shipped builds always point at production. + */ +const EDGE_WEB_URL = process.env.OPENPLC_EDGE_WEB_URL || PRODUCTION_EDGE_WEB_URL + export function createEditorSystemAdapter(): SystemPort { return { getSystemInfo(): Promise { @@ -37,5 +56,9 @@ export function createEditorSystemAdapter(): SystemPort { log(level: 'info' | 'error', message: string): void { window.bridge.log(level, message) }, + + getEdgeFrontendUrl(): string { + return EDGE_WEB_URL + }, } } diff --git a/src/middleware/shared/ports/device-port.ts b/src/middleware/shared/ports/device-port.ts index fad89c06d..db3f24fbb 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -74,6 +74,72 @@ export interface DeviceConnectionStatusPayload { reason?: 'lost' } +// --------------------------------------------------------------------------- +// VPP licensing over the held link +// --------------------------------------------------------------------------- + +/** + * What the licensing step concluded about a connected device. + * + * A discriminated union rather than a status string plus flags, so the three + * things the UI may assert stay three separate variants. In particular + * `unlicensed` (the backend says there is no purchase — demo is correct, offer to + * buy) and `checkFailed` (we could not find out) must never be rendered the same + * way: showing "Not licensed" for a rate-limited request tells someone who + * already paid to buy again. + * + * `licensed` asserts POSSESSION of a well-formed license bound to this device and + * this VPP — not that the closed license-core will run FULL. Only the core can say + * that, so the badge says "Licensed", never "Full mode". + */ +export type DeviceLicenseState = + /** A well-formed license bound to this device and this VPP is stored. */ + | { state: 'licensed'; how: 'already-stored' | 'activated' } + /** + * The device does NOT hold a valid license. + * + * `entitlementChecked` says how far we got, and the UI must branch on it: + * - `true` — the backend was asked and reported no purchase for this device. + * Demo mode is correct and BUYING is the fix. `backendReason` + * carries the backend's own wording when it gave one. + * - `false` — we only know the device is holding nothing usable; nobody has + * asked whether a purchase exists. The fix to OFFER is "check for + * a license" (a refresh), not "buy" — telling someone who already + * paid to pay again is the worst outcome this union exists to + * prevent. + */ + | { state: 'unlicensed'; entitlementChecked: boolean; backendReason?: string } + /** + * The running firmware reports no licence storage. + * + * On a licensable board this is a FIRMWARE fault, not a hardware limitation: + * every licensable VPP targets hardware that persists a licence across a + * reboot, so a board answering this was built without its storage backend. + * The fix is always "rebuild and upload", never "buy a different board" — + * which is why this variant carries no detail to vary the message with. + */ + | { state: 'unsupported' } + /** Possession could not be determined. Never render as "not licensed". */ + | { state: 'check-failed'; error: string } + +/** Result of a licensing operation over the held link. */ +export interface DeviceLicenseReport { + outcome: DeviceLicenseState + /** + * The licensing identity, 32 lowercase hex chars. Derived main-side (it needs + * `node:crypto`), so the renderer cannot compute it and must be handed it: it + * feeds the license popover, the copy button, and the buy deep link. Absent only + * when there was no usable hardware anchor, which is itself a `checkFailed`. + */ + deviceId?: string +} + +/** Arguments both licensing calls take, resolved by `resolveLicensingTarget`. */ +export interface DeviceLicenseRequest { + /** Reverse-domain VPP package id (`package.id`). */ + packageId: string +} + export interface DevicePort { /** * Get all available boards with their hardware specs and pin configurations. @@ -159,6 +225,36 @@ export interface DevicePort { /** Close a held serial link. Editor: `device:disconnect`. */ disconnect(): Promise<{ success: boolean }> + /** + * Ask the connected device what license it is holding RIGHT NOW: read FC 0x4A + * and verify the bytes. Never contacts the backend, so it is cheap and safe to + * call on a poll or a screen open. + * + * The verification is the point. A device answering `SUCCESS` does not mean the + * stored license is good — the targets disagree about what they check first — + * so a caller that trusted the status byte would show "Licensed" on a board + * running demo. + * + * Optional: only platforms that hold a device link implement it. Editor: + * `device:read-license`. + */ + readLicense?(request: DeviceLicenseRequest): Promise + + /** + * Run the FULL licensing flow over the held link: read, verify, and when the + * device holds nothing usable, ask the backend and write what it returns + * (re-reading to confirm). + * + * Separate from `readLicense` because this one can take seconds and reaches the + * network — a connect must not block on it, and it is also what the UI calls + * again after a purchase so a device gets its license without disconnecting or + * reflashing. + * + * Optional: only platforms that hold a device link implement it. Editor: + * `device:refresh-license`. + */ + refreshLicense?(request: DeviceLicenseRequest): Promise + /** * Subscribe to live serial-link status pushed by the main process (liveness * failure, upload/debug handoff). Returns an unsubscribe function. Editor: diff --git a/src/middleware/shared/ports/system-port.ts b/src/middleware/shared/ports/system-port.ts index d1eeae688..f1f4010f2 100644 --- a/src/middleware/shared/ports/system-port.ts +++ b/src/middleware/shared/ports/system-port.ts @@ -51,4 +51,19 @@ export interface SystemPort { * Web: logs via console or remote logging service. */ log(level: 'info' | 'error', message: string): void + + /** + * Absolute base URL of the Autonomy Edge **web app** — the host that serves + * user-facing pages such as `/buy`. NOT the API host: those are different + * origins (`edge.autonomylogic.com` vs `api.autonomylogic.com`), and deriving + * one from the other by string surgery would break the moment either moves. + * + * A port and not a shared constant because each platform resolves it + * differently, and both need to be pointable at a local Edge instance for + * end-to-end testing — which a hardcoded production host in shared UI cannot be. + * + * Editor: build-time env override (`OPENPLC_EDGE_WEB_URL`) over the production + * default. Web: its own env var (may be a same-origin prefix). + */ + getEdgeFrontendUrl(): string } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index eb644ceb4..80a515a3b 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -779,6 +779,29 @@ export interface PackageManifest { * integration layer (hal.source). */ precompiledLibrary?: string + /** + * On-device license-storage backend: source file(s) injected into the + * Baremetal sketch and recompiled against the editor's `license_blob.h`. + * + * PRESENCE is the signal — it is what resolves + * `TargetCapabilities.licenseStore` and what drives the backend into the + * build, so one declaration serves both and they cannot disagree. Absence + * means the board answers `LIC_UNSUPPORTED` and the licensing flow reports + * that instead of writing. + */ + licenseStore?: string | string[] + /** + * Per-VPP signing key id (e.g. `espressif-licensed-2026`). Names which key + * the backend/KMS signs this VPP's licenses with and which public key the + * closed artifact embeds. + * + * INFORMATIONAL in the editor: the activation request sends only + * `{ deviceId, packageId }` and the backend resolves its own signing key + * from the package id. The real trust root is the public key compiled into + * the VPP, not this string — it is carried for the build side and for + * diagnosing "the board stored the blob and still runs demo". + */ + licenseKeyId?: string compilerFlags?: { c_flags?: string[] cxx_flags?: string[] diff --git a/src/middleware/shared/utils/licensing/__tests__/resolve-licensing-target.test.ts b/src/middleware/shared/utils/licensing/__tests__/resolve-licensing-target.test.ts new file mode 100644 index 000000000..c2c05c416 --- /dev/null +++ b/src/middleware/shared/utils/licensing/__tests__/resolve-licensing-target.test.ts @@ -0,0 +1,93 @@ +import type { BoardInfo, VppMetadata } from '../../../ports/types' +import { resolveLicensingTarget } from '../resolve-licensing-target' + +/** A minimally-valid BoardInfo; each test overrides only what it is about. */ +function board(overrides: Partial = {}): BoardInfo { + return { + compiler: 'arduino-cli', + core: 'esp32:esp32', + preview: '', + specs: {}, + ...overrides, + } +} + +/** VPP metadata for a package id; the rest is inert filler this unit ignores. */ +function vpp(packageId: string): VppMetadata { + return { + packageId, + vendor: 'Espressif', + deviceId: 'esp32-generic', + packagePath: '/packages/espressif', + screens: {}, + moduleSystem: { enabled: false, maxSlots: 0, modules: [] }, + } +} + +const LICENSED_VPP = vpp('com.openplc.espressif-licensed') + +describe('resolveLicensingTarget', () => { + it('reports a licensable VPP with just its package id', () => { + const target = resolveLicensingTarget(board({ vpp: LICENSED_VPP, capabilities: { isLicensable: true } })) + + // Deliberately only the package id. There is no "can this board store a + // licence" companion: every licensable VPP targets hardware that persists + // one, so the answer would be constant, and the DEVICE is what gets asked. + expect(target).toEqual({ licensable: true, packageId: 'com.openplc.espressif-licensed' }) + }) + + it('is not licensable for a VPP that does not declare it', () => { + const target = resolveLicensingTarget(board({ vpp: vpp('com.openplc.espressif') })) + + expect(target).toEqual({ licensable: false, reason: 'not-licensable' }) + }) + + it('is not licensable for a plain non-VPP board', () => { + // The common case: every built-in hals.json board, and the reason this + // function has to be cheap — a connect here must carry no licensing traffic. + expect(resolveLicensingTarget(board())).toEqual({ licensable: false, reason: 'not-licensable' }) + }) + + it('is not licensable for the simulator', () => { + expect(resolveLicensingTarget(board({ compiler: 'simulator' }))).toEqual({ + licensable: false, + reason: 'not-licensable', + }) + }) + + it('is not licensable for runtime v4', () => { + expect(resolveLicensingTarget(board({ compiler: 'openplc-compiler' }))).toEqual({ + licensable: false, + reason: 'not-licensable', + }) + }) + + it('never treats an explicit isLicensable:false as licensable, even on a VPP board', () => { + const target = resolveLicensingTarget(board({ vpp: LICENSED_VPP, capabilities: { isLicensable: false } })) + + expect(target).toEqual({ licensable: false, reason: 'not-licensable' }) + }) + + it('reports a broken manifest distinctly when isLicensable is set with no package id', () => { + // Distinct from `not-licensable` on purpose: this is a manifest someone has + // to fix, not a product that is simply free. Treating it as licensable would + // mean calling activate with nothing to identify the product, then telling the + // user to buy something the editor cannot name. + const target = resolveLicensingTarget(board({ capabilities: { isLicensable: true } })) + + expect(target).toEqual({ licensable: false, reason: 'no-package-id' }) + }) + + it('treats a blank package id as missing rather than as an id', () => { + const target = resolveLicensingTarget( + board({ vpp: { ...LICENSED_VPP, packageId: ' ' }, capabilities: { isLicensable: true } }), + ) + + expect(target).toEqual({ licensable: false, reason: 'no-package-id' }) + }) + + it('handles an absent board without a null dance at the call site', () => { + expect(resolveLicensingTarget(null)).toEqual({ licensable: false, reason: 'not-licensable' }) + expect(resolveLicensingTarget(undefined)).toEqual({ licensable: false, reason: 'not-licensable' }) + }) +}) diff --git a/src/middleware/shared/utils/licensing/index.ts b/src/middleware/shared/utils/licensing/index.ts new file mode 100644 index 000000000..08ca4ff6d --- /dev/null +++ b/src/middleware/shared/utils/licensing/index.ts @@ -0,0 +1 @@ +export { type LicensingSkipReason, type LicensingTarget, resolveLicensingTarget } from './resolve-licensing-target' diff --git a/src/middleware/shared/utils/licensing/resolve-licensing-target.ts b/src/middleware/shared/utils/licensing/resolve-licensing-target.ts new file mode 100644 index 000000000..1ed791be0 --- /dev/null +++ b/src/middleware/shared/utils/licensing/resolve-licensing-target.ts @@ -0,0 +1,61 @@ +/** + * THE first node of the VPP licensing flow: does this board participate at all? + * + * Everything downstream — the extra Modbus round trips, the backend call, the + * badge, the demo prompt — hangs off this one answer. When it is "no", a connect + * is an ordinary connect: no license FCs, no HTTP, nothing on screen. That is + * the common case (every built-in board, plain Runtime v3/v4, Arduino, the + * Simulator), so it has to be the cheap, obvious path rather than a branch buried + * three layers into an activation routine. + * + * Pure function over the already-resolved capability block plus the board's VPP + * metadata — no IPC, no filesystem. Lives on the byte-identical shared surface + * because the renderer asks the same question the main process does: the badge + * must not appear on a board the flow will not run for. + */ + +import type { BoardInfo } from '../../ports/types' +import { resolveTargetCapabilities } from '../target-capabilities' + +/** + * Why the licensing flow will not run for a board. Kept as distinct reasons + * rather than a bare `false` because they are NOT interchangeable to a human: + * "this product is not sold licensed" is normal, while "declared licensable with + * no package id" is a broken manifest that someone has to fix. + */ +export type LicensingSkipReason = 'not-licensable' | 'no-package-id' + +export type LicensingTarget = + | { + licensable: true + /** Reverse-domain VPP package id (`package.id`) — the only VPP identifier + * the activation wire accepts, and the one the purchase page resolves. */ + packageId: string + } + | { licensable: false; reason: LicensingSkipReason } + +/** + * Decide whether the licensing flow applies to a board, and gather the two facts + * it needs if so. + * + * `boardInfo` is optional so callers can hand through a not-yet-resolved board + * without a null dance; absent board info is simply not licensable. + * + * A board whose manifest declares `isLicensable: true` but carries no + * `vpp.packageId` resolves to `no-package-id` rather than being treated as + * licensable. Proceeding without a package id would mean calling `/activate` + * with nothing to identify the product, or worse, deciding "no license" from a + * question that was never asked — and then telling the user to buy something the + * editor cannot name. Only a malformed or hand-edited manifest reaches this. + */ +export function resolveLicensingTarget(boardInfo: BoardInfo | null | undefined): LicensingTarget { + if (!boardInfo) return { licensable: false, reason: 'not-licensable' } + + const capabilities = resolveTargetCapabilities(boardInfo) + if (!capabilities.isLicensable) return { licensable: false, reason: 'not-licensable' } + + const packageId = boardInfo.vpp?.packageId?.trim() + if (!packageId) return { licensable: false, reason: 'no-package-id' } + + return { licensable: true, packageId } +} diff --git a/src/middleware/shared/utils/target-capabilities/presets.ts b/src/middleware/shared/utils/target-capabilities/presets.ts index 9b97fb113..bb63ad0b3 100644 --- a/src/middleware/shared/utils/target-capabilities/presets.ts +++ b/src/middleware/shared/utils/target-capabilities/presets.ts @@ -32,6 +32,11 @@ export const SIMULATOR_CAPABILITIES: TargetCapabilities = { isInProcessSimulator: true, plcStateControl: false, directUsbUpload: true, + // Licensing is never a property of a TARGET FAMILY: a VPP is what is + // sold, so only a VPP manifest can turn this on. Every preset leaves it + // off, and a board that does not declare it gets an ordinary connect + // with no licensing traffic at all. + isLicensable: false, } export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { @@ -54,6 +59,11 @@ export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { // was this flag. plcStateControl: true, directUsbUpload: false, + // Licensing is never a property of a TARGET FAMILY: a VPP is what is + // sold, so only a VPP manifest can turn this on. Every preset leaves it + // off, and a board that does not declare it gets an ordinary connect + // with no licensing traffic at all. + isLicensable: false, } export const RUNTIME_V4_CAPABILITIES: TargetCapabilities = { @@ -73,6 +83,11 @@ export const RUNTIME_V4_CAPABILITIES: TargetCapabilities = { isInProcessSimulator: false, plcStateControl: true, directUsbUpload: false, + // Licensing is never a property of a TARGET FAMILY: a VPP is what is + // sold, so only a VPP manifest can turn this on. Every preset leaves it + // off, and a board that does not declare it gets an ordinary connect + // with no licensing traffic at all. + isLicensable: false, } export const ARDUINO_CLI_CAPABILITIES: TargetCapabilities = { @@ -93,4 +108,9 @@ export const ARDUINO_CLI_CAPABILITIES: TargetCapabilities = { isInProcessSimulator: false, plcStateControl: true, directUsbUpload: true, + // Licensing is never a property of a TARGET FAMILY: a VPP is what is + // sold, so only a VPP manifest can turn this on. Every preset leaves it + // off, and a board that does not declare it gets an ordinary connect + // with no licensing traffic at all. + isLicensable: false, } diff --git a/src/middleware/shared/utils/target-capabilities/resolve.ts b/src/middleware/shared/utils/target-capabilities/resolve.ts index 740806caf..af1a8c52a 100644 --- a/src/middleware/shared/utils/target-capabilities/resolve.ts +++ b/src/middleware/shared/utils/target-capabilities/resolve.ts @@ -51,6 +51,7 @@ const EMPTY_CAPABILITIES: TargetCapabilities = { isInProcessSimulator: false, plcStateControl: false, directUsbUpload: false, + isLicensable: false, } /** @@ -90,7 +91,8 @@ function inferFromCompiler(boardInfo: BoardInfoLike): TargetCapabilities { * 1. If `boardInfo.capabilities` is present, it's authoritative. * Missing fields are filled in from the matching preset (compiler * + vpp hint), so a manifest can declare only the overrides it - * cares about (e.g. SLM-RP4 just sets `vppIo: true`). + * cares about (e.g. SLM-RP4 just sets `vppIo: true`; a licensed VPP + * sets `isLicensable: true`). * 2. Otherwise, the preset matching the legacy `compiler` field. * 3. Otherwise, an empty (everything-disabled) block. * diff --git a/src/middleware/shared/utils/target-capabilities/types.ts b/src/middleware/shared/utils/target-capabilities/types.ts index 4671405bd..6eda935e3 100644 --- a/src/middleware/shared/utils/target-capabilities/types.ts +++ b/src/middleware/shared/utils/target-capabilities/types.ts @@ -111,4 +111,30 @@ export interface TargetCapabilities { * in-process Simulator. Runtime v3 / v4 require an established * network connection. */ directUsbUpload: boolean + + /** The selected board's VPP is sold as a licensed product, so the + * licensing flow runs for it. Flows verbatim from the VPP manifest's + * `device.capabilities.isLicensable`, exactly like `vppIo`. + * + * This is THE gate on the whole flow, and the reason it is a + * capability rather than an inference: when it is `false` a connect is + * an ordinary connect — no anchor read beyond the usual + * classification, no license FCs, no backend call. Every board that + * does not declare it is `false`, which is every built-in hals.json + * board, plain Runtime v3/v4, Linux, Arduino, and the Simulator. + * + * There is deliberately NO companion "can this board store a licence" + * capability. Every licensable VPP targets hardware that persists a + * licence across a reboot — that is a product rule, not something a + * manifest gets to vary — so the answer would be `true` wherever + * `isLicensable` is true and irrelevant everywhere else. What the + * build actually needs is the storage SOURCE, which travels as + * `BoardBuildInfo.licenseStoreFiles`; a second derived boolean on top + * of it bought one diagnostic sentence and one more way for two + * representations of one fact to disagree. + * + * A licensable board that answers `LIC_UNSUPPORTED` on the wire is + * therefore a FIRMWARE fault (built without the backend), never a + * hardware limitation — and the flow says exactly that. */ + isLicensable: boolean } From 4ffaa0035af4a480bbb92d32b708be391dc9aac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 7 Aug 2026 13:28:12 +0200 Subject: [PATCH 53/79] improvement(RTOP-193): portal the licence popover and drop the duplicated connection label The licence details were a conditional
in the connect row, so opening them grew the row by ~140px: "Specs" and the board image were pushed down and the Connected label was displaced. Radix Popover + Portal renders under document.body, so the row's height never changes. A test pins it -- it asserts the panel is visible AND that the component's own subtree is unchanged apart from the attributes Radix toggles. Also removes the connection label that appeared twice. DeviceConnectButton rendered "Connected" and board.tsx rendered it again through DeviceConnectedIndicator, while the button label already IS the state (it reads "Disconnect" when connected). The ERROR label stays: there the button reads "Connect", so the failure would otherwise be invisible. The existing test is inverted rather than deleted, so reintroducing the label fails it. Co-Authored-By: Claude Opus 5 --- .../__tests__/device-license-status.test.tsx | 29 +++++++ .../editor/device/configuration/board.tsx | 27 ++----- .../components/device-license-status.tsx | 76 ++++++++++++------- .../__tests__/device-connect-button.test.tsx | 10 ++- .../device-connect-button/index.tsx | 5 +- 5 files changed, 94 insertions(+), 53 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx index 2b58fc15b..6c36ce0a6 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx @@ -94,6 +94,35 @@ describe('DeviceLicenseStatus', () => { } }) + it('keeps the details OUT of the layout flow, so opening them cannot move the page', () => { + // The regression this exists for: the panel used to be a conditional
+ // next to the trigger, so opening it grew the connect row by ~140px and + // pushed "Specs" and the board image down. A portalled popover renders under + // document.body, so the row's own subtree never changes. + const { container } = render( + , + ) + + const before = container.innerHTML + expand() + + // The panel is visible… + expect(screen.getByText('Device ID')).toBeTruthy() + // …and it is NOT inside the component's own container. + expect(container.querySelector('code')).toBeNull() + // The trigger's subtree is byte-identical apart from the attributes Radix + // toggles on it (aria-expanded / data-state). + expect(before.replace(/(aria-expanded|data-state)="[^"]*"/g, '')).toBe( + container.innerHTML.replace(/(aria-expanded|data-state)="[^"]*"/g, ''), + ) + }) + describe('details panel', () => { it('exposes the device id and copies it on request', () => { const writeText = jest.fn().mockResolvedValue(undefined) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 2692486a7..f3aa8982c 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -29,15 +29,6 @@ import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-edi import { DeviceLicenseStatus } from './components/device-license-status' import { PinMappingTable } from './components/pin-mapping-table' -/** - * Confirms the held device link on the device screen: a quiet, monochrome line - * that appears once Connect has settled on a channel a firmware answered. - */ -function DeviceConnectedIndicator({ isConnected }: { isConnected: boolean }) { - if (!isConnected) return null - return Connected -} - const Board = memo(function () { const capabilities = useCapabilities() const device = useDevice() @@ -640,13 +631,8 @@ const Board = memo(function () { onConnect={handleConnectToRuntime} onDisconnect={handleConnectToRuntime} > - {connectionStatus === 'connected' && ( - <> - {plcStatus && ( - | PLC: {plcStatus} - )} - - + {connectionStatus === 'connected' && plcStatus && ( + PLC: {plcStatus} )} @@ -725,11 +711,10 @@ const Board = memo(function () { ? { blockedReason: 'Select a communication port first' } : {})} > - - {/* Licensing sits next to the link indicator because they answer - adjacent questions about the same device — but it renders - nothing at all unless this board's VPP is sold licensed AND a - check has landed, so a free board's screen is unchanged. */} + {/* Licensing sits in the connect row because it answers an adjacent + question about the same device -- but it renders nothing at all + unless this board's VPP is sold licensed AND a check has landed, + so a free board's row is unchanged. */} {licensing.isLicensable ? ( - - - {expanded ? ( -
-
- - {label} + // Radix Popover, PORTALLED. The details used to be a conditional
in the + // flow, which is what broke the layout: opening it pushed "Specs" and the + // board image down and displaced the Connected label, because the panel is + // ~140px tall and this badge sits inside the connect row. A portalled popover + // is out of the flow entirely, so the row never changes height. + + + + + + + +
+ + + +
+

{label}

+

+ {detail} +

+
-

{detail}

- {deviceId ? (
Device ID -
- +
+ {deviceId}
-
- ) : null} -
+
+
+
) } diff --git a/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx index 6fc9bdc76..e25b15b9c 100644 --- a/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx +++ b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx @@ -30,11 +30,13 @@ describe('DeviceConnectButton', () => { expect(onConnect).not.toHaveBeenCalled() }) - it('confirms a live connection on screen', () => { - // The baremetal copy never showed this, so a connected device looked the same - // as a disconnected one apart from the button label. + it('does not repeat the connection state next to the button', () => { + // The button label IS the state: it reads "Disconnect" when connected. A second + // "Connected" beside it said the same thing twice, and on the device screen it + // sat next to the licence badge making the row read as three separate facts. render() - expect(screen.getByText('● Connected')).not.toBeNull() + expect(screen.getByRole('button', { name: 'Disconnect' })).not.toBeNull() + expect(screen.queryByText(/Connected/)).toBeNull() }) it('reports a failed attempt', () => { diff --git a/src/frontend/components/_molecules/device-connect-button/index.tsx b/src/frontend/components/_molecules/device-connect-button/index.tsx index 796c59ac4..75adc3187 100644 --- a/src/frontend/components/_molecules/device-connect-button/index.tsx +++ b/src/frontend/components/_molecules/device-connect-button/index.tsx @@ -59,7 +59,10 @@ const DeviceConnectButton = ({ {isConnecting ? 'Connecting...' : isConnected ? 'Disconnect' : 'Connect'} - {isConnected && ● Connected} + {/* No "Connected" confirmation: the button itself reads "Disconnect" when + connected, so a second label said the same thing twice. The ERROR state + below stays, because there the button reads "Connect" and the failure + would otherwise be invisible. */} {status === 'error' && ● Connection failed} {children}
From 5a4cada5b5d7411ce25f433e96e97a9abc5e4833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 7 Aug 2026 13:50:49 +0200 Subject: [PATCH 54/79] fix(RTOP-193): use vi.mock so the licence WS test runs in both runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This file is on the byte-identical shared surface, so it runs under Jest in the editor and Vitest on openplc-web. It used `jest.mock`, which is undefined under Vitest: the factory never installed, the real socket.io-client tried to dial a server, and 5 of the 6 cases died on "Connection rejected by server". `vi.mock` works in both — the editor's setup aliases `vi` to `jest`. The module import now sits AFTER the mock call, which is load-bearing: Vitest hoists `vi.mock` above imports, but ts-jest only hoists literal `jest.mock`, so under Jest the call has to physically precede the import. The alternative was adding the file to openplc-web's vitest.config exclusion list, alongside the suites that genuinely need Jest-only hoisting. Converting gets the test actually running on web instead. Co-Authored-By: Claude Opus 5 --- .../websocket-debug-transport-license.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts b/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts index 21ed46e0a..fc6dc00ab 100644 --- a/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts +++ b/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts @@ -7,11 +7,21 @@ * serial and TCP clients send, so a network target licenses through one code path * instead of a second, medium-specific one. The runtime answers these FCs at the * webserver level (covered by the runtime's own tests). + * + * MOCKING, IN BOTH RUNNERS. This file is on the byte-identical shared surface, so + * it runs under Vitest (web) and Jest (editor, which aliases `vi` to `jest`). + * Hence `vi.mock`, never `jest.mock` — the latter is undefined in Vitest, the + * factory never installs, and the real socket.io-client tries to dial a server. + * + * The `import { WebSocketDebugTransport }` below sits AFTER the `vi.mock` call, + * and that position is LOAD-BEARING: + * - Vitest hoists `vi.mock` above the imports, so it would work either way. + * - The editor's Jest does NOT hoist it (ts-jest only hoists literal + * `jest.mock`), so the call has to physically precede the import. + * Do not move it into the import block at the top. */ import type { Socket } from 'socket.io-client' -import { WebSocketDebugTransport } from '../websocket-debug-transport' - type Handler = (arg: unknown) => void type Responder = (commandHex: string) => { success: boolean; data?: string; error?: string } @@ -48,10 +58,13 @@ function makeFakeSocket(responder: Responder): Socket { let currentResponder: Responder = () => ({ success: false, error: 'no responder' }) -jest.mock('socket.io-client', () => ({ - io: jest.fn(() => makeFakeSocket((cmd) => currentResponder(cmd))), +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => makeFakeSocket((cmd) => currentResponder(cmd))), })) +// Deliberately AFTER `vi.mock` — see the module docstring. +import { WebSocketDebugTransport } from '../websocket-debug-transport' + async function connected(): Promise { const transport = new WebSocketDebugTransport({ host: '127.0.0.1', port: 8443, token: 'jwt' }) await transport.connect() From 2b148ce4498171546d1ae6e99ef72c2adf5312cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 7 Aug 2026 16:21:32 +0200 Subject: [PATCH 55/79] fix(RTOP-193): drop the licence when the selected board changes Found while reviewing this PR. `setDeviceBoard` already wipes everything else that is board-specific -- platform options, the pin-table row, vendor-screen data -- but it kept the licence report. Carried across a board switch, the report is about the wrong hardware. The badge asserts possession for a device that is no longer selected, and `buyUrl` gets built from the NEW package id paired with the OLD `deviceId`, so a purchase started from that screen binds to the wrong board. Both are reachable from the device screen: the board dropdown is live while a link is held. Two tests pin it: a real change clears the report, and re-setting the same board does not (several paths on the device screen re-set it unchanged). Co-Authored-By: Claude Opus 5 --- .../store/__tests__/device-slice.test.ts | 28 +++++++++++++++++++ src/frontend/store/slices/device/slice.ts | 10 ++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index b0dd3e54f..92b842c4f 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -349,6 +349,34 @@ describe('createDeviceSlice', () => { expect(store.getState().deviceLicense).toEqual({ phase: 'idle', report: null }) }) + it('clearDeviceBoard change drops the licence — it was verified against the OLD board', () => { + // setDeviceBoard already wipes everything else that is board-specific + // (platform options, the pin-table row, vendor-screen data). A licence + // report is just as board-specific: it was verified against the previous + // board's deviceId and its VPP's productId. Kept across a switch, the badge + // asserts possession for hardware that is no longer selected, and the buy + // link is built from the NEW package id and the OLD device id. + const store = makeStore() + store.getState().deviceActions.setDeviceBoard('ESP8266 NodeMCU') + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + + store.getState().deviceActions.setDeviceBoard('Raspberry Pi 4') + + expect(store.getState().deviceLicense).toEqual({ phase: 'idle', report: null }) + }) + + it('leaves the licence alone when setDeviceBoard is called with the same board', () => { + // The device screen re-sets the board on several paths; only an actual + // change invalidates the licence. + const store = makeStore() + store.getState().deviceActions.setDeviceBoard('ESP8266 NodeMCU') + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + + store.getState().deviceActions.setDeviceBoard('ESP8266 NodeMCU') + + expect(store.getState().deviceLicense.report).toEqual(LICENSED) + }) + it('clearDeviceDefinitions clears licensing — a new project may select another board', () => { // A "Licensed" badge carried across a project close would be an assertion // about hardware that is not even connected. diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 9181e24ce..25553bba4 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -381,7 +381,7 @@ const createDeviceSlice: StateCreator = (s setDeviceBoard: (deviceBoard): void => { const previousBoard = getState().deviceDefinitions.configuration.deviceBoard setState( - produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { + produce(({ deviceDefinitions, deviceUpdated, deviceLicense }: DeviceSlice) => { deviceUpdated.updated = true // Wipe platformOption selections when the board changes — they're // declared per-board in the VPP manifest, so a `cpu=atmega328old` @@ -407,6 +407,14 @@ const createDeviceSlice: StateCreator = (s const cfg = deviceDefinitions.configuration syncActiveBoardVendorBucket(cfg) cfg.vendorScreenData = { ...(cfg.vendorScreenDataByBoard?.[deviceBoard] ?? {}) } + // A licence report is board-specific for the same reason all of the + // above is: it was verified against the PREVIOUS board's `deviceId` + // and its VPP's `productId`. Carried across a switch, the badge + // asserts possession for hardware that is no longer selected, and + // the buy link gets built from the NEW package id paired with the + // OLD device id — binding a purchase to the wrong board. + deviceLicense.phase = 'idle' + deviceLicense.report = null } deviceDefinitions.configuration.deviceBoard = deviceBoard }), From 50219cfdc220dd37f5d1d13cc1d12e9ad4a48d97 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 7 Aug 2026 11:03:10 -0400 Subject: [PATCH 56/79] test: cover deployReachedDevice and the cp-* font scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files this PR touches sit under an enforced 100% functions/lines threshold, and both shipped a change no test executed. `deployReachedDevice` had 0% function coverage — nothing called it. It now has direct assertions for all three outcome classes, driven off a `Record` so a new outcome fails to compile until someone decides which side of "did the program reach the device?" it falls on. The `SWITCH_IN_STOP -> UPLOADED_NOT_STARTED` mapping was statement-covered but never asserted; a `deployRuntimeProgram` scenario now scripts `START:ERROR_SWITCH_STOP` and checks the outcome, that start is asked exactly once, and that the refusal logs as a warning and not an error. `cn()`'s font-scale fix shipped with `cn.test.ts` untouched. The suite now pins the exact regression — `cn('text-cp-sm', 'text-white')` keeps both classes, `cn('text-cp-xs', 'text-cp-base')` last-wins — and reads the scale out of tailwind.config.ts rather than restating it, so the "keep this list in step" comment on `cn()` is enforced instead of hoped for. Verified it fails against plain twMerge. Both files stay byte-identical with openplc-web#655. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/deploy-runtime-program.test.ts | 82 ++++++++++++++++++- src/frontend/utils/__tests__/cn.test.ts | 55 +++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/backend/shared/library/__tests__/deploy-runtime-program.test.ts b/src/backend/shared/library/__tests__/deploy-runtime-program.test.ts index e19623923..78486b345 100644 --- a/src/backend/shared/library/__tests__/deploy-runtime-program.test.ts +++ b/src/backend/shared/library/__tests__/deploy-runtime-program.test.ts @@ -1,4 +1,4 @@ -import { deployRuntimeProgram } from '../deploy-runtime-program' +import { deployReachedDevice, type DeployRuntimeProgramOutcome, deployRuntimeProgram } from '../deploy-runtime-program' import type { RuntimeCompilationStatus } from '../poll-runtime-compilation' function makeStatusFetcher(...responses: Array) { @@ -133,4 +133,84 @@ describe('deployRuntimeProgram', () => { }) expect(outcome).toBe('START_TIMEOUT') }) + + it('returns UPLOADED_NOT_STARTED when the hardware mode switch declines the start', async () => { + const start = jest.fn(makeStartFetcher('START:ERROR_SWITCH_STOP')) + const logs: Array<{ level: string; message: string }> = [] + + const outcome = await deployRuntimeProgram({ + uploadProgram: async () => ({ success: true }), + fetchCompilationStatus: makeStatusFetcher({ status: 'SUCCESS', logs: [], exit_code: 0 }), + fetchStartResponse: start, + onLog: (level, message) => logs.push({ level, message }), + pollIntervalMs: 1, + startIntervalMs: 1, + }) + + expect(outcome).toBe('UPLOADED_NOT_STARTED') + // Asked once and accepted the answer: nothing changes until someone moves + // the switch, so retrying until the deadline would only stall the deploy. + expect(start).toHaveBeenCalledTimes(1) + // Reported as a warning, never an error — the program is on the device. + expect(logs.some((l) => l.level === 'warning')).toBe(true) + expect(logs.some((l) => l.level === 'error')).toBe(false) + }) +}) + +describe('deployReachedDevice', () => { + /** + * Exhaustive by construction: a new member of `DeployRuntimeProgramOutcome` + * fails to type-check here until someone decides which side of the + * "did the program reach the device?" line it falls on. + */ + const reachedDeviceByOutcome: Record = { + STARTED: true, + UPLOADED_NOT_STARTED: true, + UPLOAD_FAILED: false, + BUILD_FAILED: false, + BUILD_TIMEOUT: false, + BUILD_ERROR: false, + START_FAILED: false, + START_TIMEOUT: false, + } + + /** Typed walk over the table above — the length check below keeps the two in step. */ + const allOutcomes: DeployRuntimeProgramOutcome[] = [ + 'STARTED', + 'UPLOADED_NOT_STARTED', + 'UPLOAD_FAILED', + 'BUILD_FAILED', + 'BUILD_TIMEOUT', + 'BUILD_ERROR', + 'START_FAILED', + 'START_TIMEOUT', + ] + + it('classifies every outcome the deploy can produce', () => { + expect(allOutcomes).toHaveLength(Object.keys(reachedDeviceByOutcome).length) + }) + + it('is true when the runtime took the program and ran it', () => { + expect(deployReachedDevice('STARTED')).toBe(true) + }) + + it('is true when the runtime took the program but declined to run it', () => { + // The mode switch reads STOP. Reporting this as a failed upload sends the + // user looking for a problem that does not exist. + expect(deployReachedDevice('UPLOADED_NOT_STARTED')).toBe(true) + }) + + it('is false for every outcome where the program never landed', () => { + const failures = allOutcomes.filter((outcome) => !reachedDeviceByOutcome[outcome]) + expect(failures).toHaveLength(6) + for (const outcome of failures) { + expect(deployReachedDevice(outcome)).toBe(false) + } + }) + + it('agrees with the full outcome table', () => { + for (const outcome of allOutcomes) { + expect(deployReachedDevice(outcome)).toBe(reachedDeviceByOutcome[outcome]) + } + }) }) diff --git a/src/frontend/utils/__tests__/cn.test.ts b/src/frontend/utils/__tests__/cn.test.ts index 087bbc300..e19d522a6 100644 --- a/src/frontend/utils/__tests__/cn.test.ts +++ b/src/frontend/utils/__tests__/cn.test.ts @@ -1,5 +1,25 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + import { cn } from '../cn' +/** + * The `cp-*` font scale is declared twice: once in `tailwind.config.ts`, where + * it generates the classes, and once inside `cn()`, where twMerge has to be + * told those names are sizes. Read the scale out of the config rather than + * restating it here, so the "keep this list in step" comment on `cn()` is + * enforced by this suite instead of hoped for — a fourth size added to + * Tailwind and forgotten in `cn()` fails below rather than silently + * evaporating at runtime, which is exactly how the Connect button lost its + * `text-cp-sm` with no warning and no build error. + */ +function tailwindFontSizeNames(): string[] { + const config = readFileSync(join(process.cwd(), 'tailwind.config.ts'), 'utf8') + const block = /fontSize:\s*\{([\s\S]*?)\n\s*\},/.exec(config) + if (!block) throw new Error('No `fontSize` block found in tailwind.config.ts') + return [...block[1].matchAll(/'([\w-]+)':/g)].map(([, name]) => name) +} + describe('cn', () => { it('merges class names', () => { expect(cn('foo', 'bar')).toBe('foo bar') @@ -26,3 +46,38 @@ describe('cn', () => { expect(cn(['foo', 'bar'])).toBe('foo bar') }) }) + +describe('cn — the custom cp-* font scale', () => { + const fontSizes = tailwindFontSizeNames() + + it('reads the scale out of tailwind.config.ts', () => { + expect(fontSizes).toEqual(['cp-xs', 'cp-sm', 'cp-base']) + }) + + it('pins the reported regression: text-cp-sm survives beside text-white', () => { + // Plain twMerge reads both as colours and drops the size, leaving the + // Connect button at the browser default. + expect(cn('text-cp-sm', 'text-white')).toBe('text-cp-sm text-white') + }) + + it('pins the other half: two cp-* sizes conflict, and the last one wins', () => { + expect(cn('text-cp-xs', 'text-cp-base')).toBe('text-cp-base') + }) + + it.each(fontSizes)('keeps text-%s beside a text colour', (size) => { + expect(cn(`text-${size}`, 'text-white')).toBe(`text-${size} text-white`) + }) + + it('resolves any pair of cp-* sizes to the later one', () => { + for (const first of fontSizes) { + for (const second of fontSizes) { + if (first === second) continue + expect(cn(`text-${first}`, `text-${second}`)).toBe(`text-${second}`) + } + } + }) + + it('still lets a cp-* size override a stock Tailwind size', () => { + expect(cn('text-sm', 'text-cp-base')).toBe('text-cp-base') + }) +}) From ecd880b96bc9acb6083054bd20ecac27fcfc5934 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 7 Aug 2026 11:15:16 -0400 Subject: [PATCH 57/79] fix(ui): answer the run/stop tooltip in the order the button blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reason chain asked about TRANSITIONING before the connection, so a disconnected target whose last polled `plcStatus` was TRANSITIONING got "PLC is changing state..." — a state we last saw, not the reason the button is inert. `plcStatus` is polled and survives the drop, so the stale value outlives the session that produced it. Reordering to match `plcControlBlocked`'s own order — unsupported, then no session, then a transition in flight — makes the tail unreachable except for the one reason that is left. Also unwraps `plcControlBlocked` onto one line: at 118 chars it fits inside Prettier's 120, and the manual wrap has been failing the format gate (which `sync` and `complete-build` both hang off) since 20ff67e0e. Found by CodeRabbit on #996. Mirrored byte-identically in openplc-web#655. Co-Authored-By: Claude Opus 5 (1M context) --- .../workspace-activity-bar/default.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index e2b86ef82..47a547008 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -110,15 +110,23 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // everything except PING and STATUS, and the state it will settle on is not // decided yet, so the icon is drawn from a state that is about to change. // Clicking then cannot do what it appears to. + // + // The reason chain runs in the same order as the blocks it explains, most + // fundamental first: a target that cannot do run/stop at all, then no session + // to send over, then a transition in flight. Asking about the transition first + // would answer "PLC is changing state..." to someone who is not connected, + // reporting a state we last saw rather than the reason the button is inert — + // `plcStatus` is polled and survives the drop. Reached only when blocked, so + // the tail needs no test of its own: supported and connected and still blocked + // leaves exactly one reason. const plcStateControlSupported = resolveTargetCapabilities(currentBoardInfo).plcStateControl const plcTransitioning = plcStatus === 'TRANSITIONING' - const plcControlBlocked = - !plcStateControlSupported || deviceConnectionStatus !== 'connected' || plcTransitioning + const plcControlBlocked = !plcStateControlSupported || deviceConnectionStatus !== 'connected' || plcTransitioning const plcControlBlockedReason = !plcStateControlSupported ? 'This target does not support Start/Stop from the editor' - : plcTransitioning - ? 'PLC is changing state...' - : 'Connect to the target first' + : deviceConnectionStatus !== 'connected' + ? 'Connect to the target first' + : 'PLC is changing state...' // The emulator stopping is a session ending, and a debug session riding it ends // with it — which the drop handler below already does for every target. This From 97bf8edecc475d8391731d4756516939a6961039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 6 Aug 2026 10:56:25 +0200 Subject: [PATCH 58/79] refactor(semver): one parser for every version comparison (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codebase answered "is X at least Y" two different ways, and they disagreed on exactly the inputs that show up in the field: input | semver.ts | runtime-version-gate.ts ------------|----------------|------------------------ "v4" | 4.0.0 | rejected "4.1" | 4.1.0 | rejected "garbage" | 0.0.0 (lowest) | rejected Neither was wrong for its own caller: a corrupt package manifest should not break a card in the catalog UI, and an unidentifiable runtime must not receive an upload. What was wrong is that the DIFFERENCE lived in two separate parsers, where nothing named it and nothing tested it side by side — and DOPE-448 adds three more comparisons on top. Now one parse, one ordering, and the lenient-vs-strict choice made by name at the call site: parseVersionStrict fails closed, parseVersionLenient degrades to 0.0.0. parseRuntimeVersion and compareSemver become thin wrappers, so no call site changes and their tests pass untouched. It lives in frontend/utils/ rather than backend/shared/ because the layer rules let backend-shared import utils and not the reverse, and both the VPP surface and the runtime gates need it. No layer exception. Also adds the two message builders the gates will use, and renames MIN_STRUCPP_RUNTIME_VERSION to MIN_RUNTIME_VERSION (old name kept as an alias) so the constant says what it is rather than why it appeared. Co-Authored-By: Claude Opus 5 --- .../shared/firmware/runtime-version-gate.ts | 100 ++++++++--- src/frontend/utils/__tests__/semver.test.ts | 158 ++++++++++++++++- src/frontend/utils/semver.ts | 159 +++++++++++++++--- 3 files changed, 367 insertions(+), 50 deletions(-) diff --git a/src/backend/shared/firmware/runtime-version-gate.ts b/src/backend/shared/firmware/runtime-version-gate.ts index 179010a1a..5efedb694 100644 --- a/src/backend/shared/firmware/runtime-version-gate.ts +++ b/src/backend/shared/firmware/runtime-version-gate.ts @@ -22,37 +22,41 @@ * orchestrator-agent the same way). */ -/** Minimum runtime version that speaks the STruC++ wire format. */ -export const MIN_STRUCPP_RUNTIME_VERSION = '4.1.0' +// Relative import on purpose: `npm run validate:arch` only inspects relative +// specifiers, so this path is actually checked against the layer rules — +// `backend-shared -> utils` is allowed, and using `@root/` here would have +// skipped the check rather than passed it. +import type { ParsedVersion } from '../../../frontend/utils/semver' +import { parseVersionStrict } from '../../../frontend/utils/semver' -export interface ParsedRuntimeVersion { - major: number - minor: number - patch: number - /** Pre-release identifier (e.g. `rc.3`) if present, otherwise undefined. */ - prerelease?: string -} +/** + * Oldest runtime this editor will upload to — the editor's own + * `minRuntimeVersion` declaration (DOPE-448). 4.1.0 is the floor + * because that is where the STruC++ pipeline landed. + * + * `MIN_STRUCPP_RUNTIME_VERSION` is kept as an alias so existing call + * sites and their tests keep working; new code should use the plain + * name, which says what the constant is rather than why it was + * introduced. + */ +export const MIN_RUNTIME_VERSION = '4.1.0' + +/** @deprecated Use `MIN_RUNTIME_VERSION`. */ +export const MIN_STRUCPP_RUNTIME_VERSION = MIN_RUNTIME_VERSION + +/** @deprecated Use `ParsedVersion` from `shared/utils/version-compare`. */ +export type ParsedRuntimeVersion = ParsedVersion /** * Parses a runtime version string. Returns null when the string - * doesn't carry enough information to compare against - * `MIN_STRUCPP_RUNTIME_VERSION` — e.g. the legacy `"v4"` or `"dev"` - * builds. Callers treat null as "incompatible". + * doesn't carry enough information to compare — e.g. the legacy `"v4"` + * or `"dev"` builds. Callers treat null as "incompatible". + * + * Delegates to the shared strict parser so the VPP surface and the + * runtime gates can never drift apart on what `"v4"` or `"4.1"` means. */ export function parseRuntimeVersion(raw: string | null | undefined): ParsedRuntimeVersion | null { - if (!raw) return null - const trimmed = raw.trim() - if (trimmed.length === 0) return null - // Require all three numeric components — `v4` alone is the legacy - // hardcoded header and must be rejected. - const match = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/) - if (!match) return null - return { - major: parseInt(match[1], 10), - minor: parseInt(match[2], 10), - patch: parseInt(match[3], 10), - prerelease: match[4], - } + return parseVersionStrict(raw) } /** @@ -104,7 +108,51 @@ export function describeIncompatibleRuntime(raw: string | null | undefined): str const reported = raw && raw.trim().length > 0 ? raw.trim() : 'unknown' return ( `Runtime version ${reported} is not compatible with this editor. ` + - `Upload requires OpenPLC Runtime v${MIN_STRUCPP_RUNTIME_VERSION} or newer (STruC++ pipeline). ` + + `Upload requires OpenPLC Runtime v${MIN_RUNTIME_VERSION} or newer (STruC++ pipeline). ` + `Please upgrade the runtime on the target device before pushing this build.` ) } + +/** + * The other direction: this runtime declared a `minEditorVersion` at + * `GET /api/capabilities` and this editor is below it. + * + * Names both versions and the single action that fixes it — a bare + * "incompatible versions" turns into a support ticket. + */ +export function describeEditorTooOldForRuntime(args: { + runtimeVersion: string | null | undefined + minEditorVersion: string + editorVersion: string + deviceLabel?: string +}): string { + const runtime = args.runtimeVersion?.trim() ?? 'unknown' + const where = args.deviceLabel ? ` on ${args.deviceLabel}` : '' + return ( + `Runtime ${runtime}${where} requires OpenPLC Editor ${args.minEditorVersion} or newer. ` + + `This editor is ${args.editorVersion}. ` + + `Update the editor, or connect to a runtime that accepts ${args.editorVersion}.` + ) +} + +/** + * The VPP providing the selected board declares a runtime floor the + * connected runtime does not meet. + * + * Names the package's board rather than the package id — the board is + * what the user picked and recognises. + */ +export function describeVppRuntimeMismatch(args: { + boardTarget: string + minRuntimeVersion: string + runtimeVersion: string | null | undefined + deviceLabel?: string +}): string { + const runtime = args.runtimeVersion?.trim() ?? 'unknown' + const where = args.deviceLabel ? `The runtime at ${args.deviceLabel} reports` : 'The connected runtime reports' + return ( + `Board "${args.boardTarget}" requires OpenPLC Runtime v${args.minRuntimeVersion} or newer. ` + + `${where} ${runtime}. ` + + `Upgrade the runtime on that device, or select a board supported by ${runtime}.` + ) +} diff --git a/src/frontend/utils/__tests__/semver.test.ts b/src/frontend/utils/__tests__/semver.test.ts index 97ec6d22f..61fd6c16e 100644 --- a/src/frontend/utils/__tests__/semver.test.ts +++ b/src/frontend/utils/__tests__/semver.test.ts @@ -1,4 +1,160 @@ -import { compareSemver, isCompatibleEditorVersion } from '../semver' +import { + compareParsedVersions, + compareSemver, + isCompatibleEditorVersion, + isVersionAtLeast, + parseVersionLenient, + parseVersionStrict, +} from '../semver' + +describe('parseVersionStrict', () => { + it('parses a plain three-part version', () => { + expect(parseVersionStrict('4.1.9')).toEqual({ major: 4, minor: 1, patch: 9, prerelease: undefined }) + }) + + it('accepts the tag-style v prefix the runtime reports', () => { + expect(parseVersionStrict('v4.2.0')).toEqual({ major: 4, minor: 2, patch: 0, prerelease: undefined }) + }) + + it('captures pre-release and build suffixes without failing', () => { + expect(parseVersionStrict('4.1.0-rc.3')?.prerelease).toBe('rc.3') + expect(parseVersionStrict('4.1.0+build.5')?.prerelease).toBe('build.5') + }) + + it('tolerates surrounding whitespace', () => { + expect(parseVersionStrict(' 4.1.9 ')).toEqual({ major: 4, minor: 1, patch: 9, prerelease: undefined }) + }) + + // The whole point of the strict parser: these are the values a runtime in + // the field actually reports when it cannot identify itself, and every one + // of them must stay unparseable so a gate fails closed instead of guessing. + it.each([ + ['v4', 'the legacy hardcoded header'], + ['4.1', 'a two-part version'], + ['dev', 'a source build with no CI tag'], + ['garbage', 'anything else'], + ['', 'an empty string'], + ])('returns null for %p (%s)', (input) => { + expect(parseVersionStrict(input)).toBeNull() + }) + + it('returns null for null and undefined', () => { + expect(parseVersionStrict(null)).toBeNull() + expect(parseVersionStrict(undefined)).toBeNull() + }) +}) + +describe('parseVersionLenient', () => { + it('parses a plain three-part version', () => { + expect(parseVersionLenient('4.1.9')).toEqual({ major: 4, minor: 1, patch: 9 }) + }) + + it('fills missing components with zero', () => { + expect(parseVersionLenient('4.1')).toEqual({ major: 4, minor: 1, patch: 0 }) + expect(parseVersionLenient('4')).toEqual({ major: 4, minor: 0, patch: 0 }) + }) + + it('strips the v prefix and any suffix before parsing', () => { + expect(parseVersionLenient('v4.1.9')).toEqual({ major: 4, minor: 1, patch: 9 }) + expect(parseVersionLenient('4.1.9-rc.1')).toEqual({ major: 4, minor: 1, patch: 9 }) + expect(parseVersionLenient('4.1.9+build.5')).toEqual({ major: 4, minor: 1, patch: 9 }) + }) + + // Degrading to the lowest possible version means a corrupt manifest field + // loses every comparison rather than winning one. + it.each([ + ['garbage', 'a non-numeric string'], + ['abc.def.ghi', 'non-numeric components'], + ['', 'an empty string'], + ])('degrades %p to 0.0.0 (%s)', (input) => { + expect(parseVersionLenient(input)).toEqual({ major: 0, minor: 0, patch: 0 }) + }) + + it('degrades null and undefined to 0.0.0', () => { + expect(parseVersionLenient(null)).toEqual({ major: 0, minor: 0, patch: 0 }) + expect(parseVersionLenient(undefined)).toEqual({ major: 0, minor: 0, patch: 0 }) + }) +}) + +describe('compareParsedVersions', () => { + const v = (major: number, minor: number, patch: number) => ({ major, minor, patch }) + + it('orders by major first', () => { + expect(compareParsedVersions(v(5, 0, 0), v(4, 9, 9))).toBe(1) + expect(compareParsedVersions(v(4, 9, 9), v(5, 0, 0))).toBe(-1) + }) + + it('orders by minor when majors match', () => { + expect(compareParsedVersions(v(4, 2, 0), v(4, 1, 99))).toBe(1) + expect(compareParsedVersions(v(4, 1, 99), v(4, 2, 0))).toBe(-1) + }) + + it('orders by patch when major and minor match', () => { + expect(compareParsedVersions(v(4, 1, 10), v(4, 1, 9))).toBe(1) + expect(compareParsedVersions(v(4, 1, 9), v(4, 1, 10))).toBe(-1) + }) + + it('returns 0 for equal triples', () => { + expect(compareParsedVersions(v(4, 1, 9), v(4, 1, 9))).toBe(0) + }) + + it('ignores pre-release when ordering', () => { + // Load-bearing deviation from strict semver: the rc builds on a version + // line ARE the builds shipping that line's features, so treating them as + // "less than" the release would reject runtimes that work. + const rc = { ...v(4, 1, 0), prerelease: 'rc.3' } + expect(compareParsedVersions(rc, v(4, 1, 0))).toBe(0) + expect(compareParsedVersions(v(4, 1, 0), rc)).toBe(0) + }) +}) + +describe('isVersionAtLeast', () => { + it('passes when the candidate is above the floor', () => { + expect(isVersionAtLeast('4.2.10', '4.2.1')).toBe(true) + }) + + it('passes when the candidate sits exactly on the floor', () => { + expect(isVersionAtLeast('4.2.1', '4.2.1')).toBe(true) + }) + + it('fails when the candidate is below the floor', () => { + expect(isVersionAtLeast('4.2.0', '4.2.1')).toBe(false) + }) + + it('passes a pre-release build of the required version', () => { + expect(isVersionAtLeast('v4.1.9-rc.1', '4.1.9')).toBe(true) + }) + + // A peer that asks for nothing gets nothing enforced — this is what keeps + // runtimes predating /api/capabilities working unchanged. + const NOTHING_DECLARED: Array<[string | null | undefined, string]> = [ + [undefined, 'undefined'], + [null, 'null'], + ['', 'an empty string'], + ] + + it.each(NOTHING_DECLARED)('passes when the floor is %p (%s)', (floor) => { + expect(isVersionAtLeast('4.2.0', floor)).toBe(true) + }) + + it('passes when the floor itself is unparseable, since it declares nothing', () => { + expect(isVersionAtLeast('4.2.0', 'garbage')).toBe(true) + expect(isVersionAtLeast('4.2.0', 'v4')).toBe(true) + }) + + // Fails closed: an unidentifiable peer never clears a real floor. + const UNIDENTIFIABLE: Array<[string | null | undefined, string]> = [ + ['v4', 'the legacy header'], + ['dev', 'a source build'], + ['garbage', 'a corrupt value'], + [null, 'an unreachable peer'], + [undefined, 'a missing value'], + ] + + it.each(UNIDENTIFIABLE)('fails when the candidate is %p (%s) and a real floor exists', (candidate) => { + expect(isVersionAtLeast(candidate, '4.1.0')).toBe(false) + }) +}) describe('compareSemver', () => { it('returns 0 when versions are identical', () => { diff --git a/src/frontend/utils/semver.ts b/src/frontend/utils/semver.ts index 673a026eb..39a14f2c6 100644 --- a/src/frontend/utils/semver.ts +++ b/src/frontend/utils/semver.ts @@ -1,37 +1,150 @@ /** - * Tiny semver helpers used by the VPP catalog browser to compare a package - * version's `minEditorVersion` against the running editor's `APP_VERSION`. + * Single source of truth for comparing OpenPLC version strings. * - * Intentionally local — adding the full `semver` npm dependency for two - * comparisons would inflate the renderer bundle for no real gain. Pre-release - * suffixes (`-rc.1`, `+build.5`) are stripped before parsing; this matches - * what arduino-cli does when matching boards.txt menu constraints, and we - * don't currently publish pre-release VPPs. + * Four independent compatibility questions are decided by comparing two + * version strings (DOPE-448): * - * Malformed strings degrade to `0.0.0` so a corrupt manifest in the wild - * doesn't crash the UI — it just compares as the lowest possible version. + * 1. is this runtime new enough for this editor? (`MIN_RUNTIME_VERSION`) + * 2. is this editor new enough for this runtime? (`minEditorVersion` from + * `GET /api/capabilities`) + * 3. is this editor new enough for this VPP? (`package.minEditorVersion`) + * 4. is this runtime new enough for this VPP? (`package.minRuntimeVersion`) + * + * This file used to answer only #3, with `firmware/runtime-version-gate.ts` + * carrying its own parser for the runtime side. The two disagreed on exactly + * the inputs that show up in the field: + * + * input | catalog parser | runtime parser + * -------------|------------------|---------------- + * "v4" | 4.0.0 | rejected + * "4.1" | 4.1.0 | rejected + * "garbage" | 0.0.0 (lowest) | rejected + * + * Neither behaviour was wrong for its own caller. A package manifest carrying + * a corrupt version should not crash the catalog UI, and an unidentifiable + * runtime must not receive an upload. What was wrong is that the DIFFERENCE + * lived in two separate parsers, where nothing named it and nothing tested it + * side by side. + * + * So: one parse, one comparison, and the lenient-vs-strict choice made + * explicitly by name at the call site. `parseVersionStrict` returns null for + * anything it cannot fully identify — callers that must fail closed use it. + * `parseVersionLenient` fills missing components with 0 and degrades garbage to + * 0.0.0 — callers rendering untrusted metadata use it. + * + * Pre-release and build suffixes (`-rc.1`, `+build.5`) are parsed but do NOT + * affect ordering: `4.1.0-rc.3` compares equal to `4.1.0`. This is deliberate + * and load-bearing for the runtime gate — the rc tags on a version line ARE + * the builds shipping that line's features, so treating them as "less than" + * the release (strict semver's rule) would reject runtimes that work. + * + * Lives in `frontend/utils/` rather than `backend/shared/` on purpose: the + * architecture rules let `backend-shared` import `utils` but not the reverse, + * and both the VPP surface and the runtime gate need this. No layer exception + * required. */ -type Triple = readonly [number, number, number] +export interface ParsedVersion { + major: number + minor: number + patch: number + /** Pre-release identifier (e.g. `rc.3`) when present. Never affects ordering. */ + prerelease?: string +} + +/** `v4.1.0-rc.3` / `4.1.0` — all three numeric components required. */ +const STRICT_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+](.+))?$/ -function parseSemver(input: string): Triple { - const stripped = input.split(/[-+]/)[0] - const parts = stripped.split('.') - const major = Number.parseInt(parts[0] ?? '', 10) - const minor = Number.parseInt(parts[1] ?? '', 10) - const patch = Number.parseInt(parts[2] ?? '', 10) - return [Number.isFinite(major) ? major : 0, Number.isFinite(minor) ? minor : 0, Number.isFinite(patch) ? patch : 0] +/** + * Parse a version string, requiring all three numeric components. + * + * Returns null for anything else — `"v4"`, `"4.1"`, `"dev"`, `""`, null. Use + * this when an unidentifiable version must block an action: the caller cannot + * accidentally treat "I don't know" as "old enough" or "new enough", because + * there is no number to compare. + */ +export function parseVersionStrict(raw: string | null | undefined): ParsedVersion | null { + if (!raw) return null + const match = raw.trim().match(STRICT_RE) + if (!match) return null + return { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2], 10), + patch: Number.parseInt(match[3], 10), + prerelease: match[4], + } } -export function compareSemver(a: string, b: string): -1 | 0 | 1 { - const [aMajor, aMinor, aPatch] = parseSemver(a) - const [bMajor, bMinor, bPatch] = parseSemver(b) - if (aMajor !== bMajor) return aMajor > bMajor ? 1 : -1 - if (aMinor !== bMinor) return aMinor > bMinor ? 1 : -1 - if (aPatch !== bPatch) return aPatch > bPatch ? 1 : -1 +/** + * Parse a version string, filling in whatever is missing with zero. + * + * `"4.1"` becomes 4.1.0; `"garbage"` and `""` become 0.0.0 — the lowest + * possible version, so a corrupt value loses every comparison instead of + * winning one. Use this for untrusted metadata being rendered rather than + * enforced, where a malformed field should degrade the display and not throw. + */ +export function parseVersionLenient(raw: string | null | undefined): ParsedVersion { + // Deliberately unanchored at the end: it consumes as many leading numeric + // components as it finds and ignores whatever follows, so `4.1.9-rc.1` and + // `4.1` both parse without a separate suffix-stripping pass. + const match = (raw ?? '').trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/) + if (!match) return { major: 0, minor: 0, patch: 0 } + const toInt = (value: string | undefined): number => { + const parsed = Number.parseInt(value ?? '', 10) + return Number.isFinite(parsed) ? parsed : 0 + } + return { major: toInt(match[1]), minor: toInt(match[2]), patch: toInt(match[3]) } +} + +/** + * Order two parsed versions. Pre-release suffixes are ignored (see the module + * comment) — only the numeric triple decides. + */ +export function compareParsedVersions(a: ParsedVersion, b: ParsedVersion): -1 | 0 | 1 { + if (a.major !== b.major) return a.major > b.major ? 1 : -1 + if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1 + if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1 return 0 } +/** + * `candidate >= minimum`, where an unparseable `candidate` fails closed. + * + * This is the shape every DOPE-448 gate wants: "may I proceed?" answered + * `false` when the peer cannot be identified. An absent `minimum` means no + * constraint was declared, which is a pass — a peer that asks for nothing gets + * nothing enforced, which is what keeps runtimes predating + * `/api/capabilities` working unchanged. + */ +export function isVersionAtLeast(candidate: string | null | undefined, minimum: string | null | undefined): boolean { + if (!minimum) return true + const min = parseVersionStrict(minimum) + if (!min) return true // a floor we cannot read declares nothing + const version = parseVersionStrict(candidate) + if (!version) return false // an unidentifiable peer never clears a real floor + return compareParsedVersions(version, min) >= 0 +} + +// --------------------------------------------------------------------------- +// Lenient VPP-surface helpers +// --------------------------------------------------------------------------- + +/** + * Lenient comparison, used by the VPP catalog and the package install gate. + * + * Lenient is right *here* specifically: a package manifest is untrusted + * third-party metadata, and a corrupt `version` string should sort as the + * lowest possible version rather than break a card in the catalog UI. Gates + * deciding whether to talk to a runtime use `isVersionAtLeast` instead. + */ +export function compareSemver(a: string, b: string): -1 | 0 | 1 { + return compareParsedVersions(parseVersionLenient(a), parseVersionLenient(b)) +} + +/** + * True when `current` satisfies `minRequired`. An absent or empty minimum + * means the package declared no floor, which is a pass. + */ export function isCompatibleEditorVersion(minRequired: string | undefined, current: string): boolean { if (!minRequired) return true return compareSemver(current, minRequired) >= 0 From 193d710178edfa74dc69f79fc34f74648f7a30f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 6 Aug 2026 10:56:44 +0200 Subject: [PATCH 59/79] feat(vpp): enforce minEditorVersion when installing a package (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest.package.minEditorVersion has been required by the schema all along, and openplc-packages/docs/package-format.md:69 promised the editor refuses to install a package requiring a newer editor. It did not. The only consumer was the catalog UI (catalog-browser.tsx), which renders an "Editor outdated" button state. package-manager-module.ts::install — the trust boundary that both the remote install and the local "Add from file…" flow converge on, which already validates the schema, verifies the signature and hardens the path — never looked at the field. So a .vpp dragged in from disk ignored it completely, and a package installed before an editor downgrade kept loading. The check now sits next to the signature verification, so one gate covers both entry paths. This is also the mechanism that answers "what about a VPP needing a UI engine the editor may not have": the engine shipped in some release, the package declares that release as its floor, an older editor cannot install it. No capability enumeration needed. minEditorVersion and minRuntimeVersion are typed in the zod schema rather than left to .passthrough() — a field a gate reads should not reach it as unknown. Both stay optional so packages built before they existed keep installing. Co-Authored-By: Claude Opus 5 --- .../package-manager/package-manager-module.ts | 52 +++++++++++++++++++ .../shared/ports/package-manifest-schema.ts | 11 ++++ src/middleware/shared/ports/types.ts | 12 +++++ 3 files changed, 75 insertions(+) diff --git a/src/backend/editor/package-manager/package-manager-module.ts b/src/backend/editor/package-manager/package-manager-module.ts index 7319b5837..e588c826f 100644 --- a/src/backend/editor/package-manager/package-manager-module.ts +++ b/src/backend/editor/package-manager/package-manager-module.ts @@ -3,6 +3,8 @@ import extract from 'extract-zip' import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs' import { join } from 'path' +import { APP_VERSION } from '../../../frontend/data/constants/app-version' +import { isCompatibleEditorVersion } from '../../../frontend/utils/semver' import { PackageManifestSchema } from '../../../middleware/shared/ports/package-manifest-schema' import { validatePathId } from '../../shared/utils/path-safety' import { TRUSTED_PACKAGE_KEYS } from '../../shared/utils/vpp/trusted-keys' @@ -79,6 +81,28 @@ class PackageManagerModule { } } + // Compatibility floor (DOPE-448). This is the ONLY place the editor + // enforces `minEditorVersion`, and it sits here because both entry paths + // — remote catalog install and the local "Add from file…" picker — + // converge on this method. The catalog UI's "Editor outdated" button + // state is a courtesy that stops the user earlier; it is not the gate, + // and before this check existed a `.vpp` dragged in from disk bypassed + // the constraint entirely. + // + // A package declares a floor when it needs an editor feature it cannot + // work without — a UI engine, a new screen widget, a layout the renderer + // learned in some release. Installing it on an older editor produces a + // board that renders wrong rather than an error, so refuse up front. + if (!isCompatibleEditorVersion(manifest.package.minEditorVersion, APP_VERSION)) { + return { + success: false, + error: + `Package "${manifest.package.name}" ${manifest.package.version} requires ` + + `OpenPLC Editor ${manifest.package.minEditorVersion} or newer. This editor is ${APP_VERSION}. ` + + `Update the editor, or install an older version of this package.`, + } + } + // Validate package.id BEFORE using it as a path component. Without // this, a malicious .vpp with `"id": "../../something"` would have // `targetDir` resolve outside packagesDir and the rmSync below @@ -278,6 +302,34 @@ class PackageManagerModule { return pkg?.path ?? null } + /** + * `package.minRuntimeVersion` of the installed package that provides + * `boardName`, or null when no installed package does, when the + * matching device is not a `runtime-v4` target, or when the package + * declares no floor (DOPE-448). + * + * Board lookup is by device *name* because that is the identifier the + * compile pipeline carries as `boardTarget` — the same match + * `handleVendorPluginPackaging` performs. + * + * Only runtime-v4 devices can carry a meaningful floor: their HAL is + * plugin code built against the runtime's API. An `arduino-cli` + * device never talks to the runtime, so a floor there would be a + * claim nothing can check — openplc-packages' `validate.ts` rejects + * it at authoring time, and this returns null if one slips through. + */ + getRuntimeFloorForBoard(boardName: string): string | null { + for (const pkg of this.listInstalled()) { + const manifest = this.getInstalledPackageManifest(pkg.packageId) + if (!manifest) continue + const device = manifest.devices.find((d) => d.name === boardName) + if (!device) continue + if (device.target.type !== 'runtime-v4') return null + return manifest.package.minRuntimeVersion ?? null + } + return null + } + private readRegistry(): PackageRegistry { if (!existsSync(this.registryPath)) { return { formatVersion: '1.0', packages: {} } diff --git a/src/middleware/shared/ports/package-manifest-schema.ts b/src/middleware/shared/ports/package-manifest-schema.ts index 815230e3e..ef23107b9 100644 --- a/src/middleware/shared/ports/package-manifest-schema.ts +++ b/src/middleware/shared/ports/package-manifest-schema.ts @@ -44,6 +44,17 @@ export const PackageManifestSchema = z id: z.string().min(1), name: z.string().min(1), version: z.string().min(1), + // Compatibility floors (DOPE-448). Optional on purpose: packages built + // before these fields existed must keep installing, and a package that + // declares no floor declares no constraint. They are typed here rather + // than left to `.passthrough()` because the install gate compares them + // — a field a gate reads should not reach it as `unknown`. + // + // Authoring-side rules (minRuntimeVersion required iff a device targets + // runtime-v4, rejected otherwise) live in openplc-packages' + // `scripts/validate.ts`, per this file's split of responsibilities. + minEditorVersion: z.string().min(1).optional(), + minRuntimeVersion: z.string().min(1).optional(), }) .passthrough(), devices: z.array(z.object({}).passthrough()).min(1), diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index eb644ceb4..c40fe5f20 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -730,7 +730,19 @@ export interface PackageManifest { } description: string license?: string + /** + * Oldest editor that may install this package. The install gate refuses a + * package whose floor is above `APP_VERSION` — this is how a package + * requires an editor feature it cannot work without (DOPE-448). + */ minEditorVersion?: string + /** + * Oldest runtime this package works with. Declared only by packages with a + * `runtime-v4` target, whose plugin code executes inside the runtime + * process. Checked at compile time, not install time: the target device is + * unknown until the user connects to one. + */ + minRuntimeVersion?: string } devices: Array<{ id: string From 893b617d28b279db4c8982fd0eb28417bcaf36ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 6 Aug 2026 10:57:04 +0200 Subject: [PATCH 60/79] feat(compile): block upload when a declared version floor is not met (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two directions that had no enforcement at all. Runtime -> editor: probe-runtime-version.ts now prefers GET /api/capabilities, which carries the runtime version and its minEditorVersion in one round-trip, and falls back to /api/version otherwise. A runtime predating the endpoint answers 401 from the / catch-all (not 404) — both land in the same fallback, silently, because that 401 is the normal answer from every device in the field and a warning there would nag on every upload. VPP -> runtime: packageVppPlugin surfaces the package's minRuntimeVersion, compared against the connected runtime before the upload. It cannot be checked at install time — the target device is unknown until the user connects — and it must be checked before sending, because the failure it prevents is vendor plugin code that loads on a live PLC and dies at scan time. Both gates are inert against everything currently deployed, in two independent ways: a runtime that declares no floor passes, and a caller that passes no editorVersion passes. editorVersion is injected through RunCompilePipelineArgs rather than imported: APP_VERSION lives in frontend/data/, which the layer rules keep out of backend/shared/ — correctly, since which build is running is a fact about the host app, not about the compile. Same reasoning for getVppRuntimeFloor on the adapter context, which additionally avoids pulling the Electron-dependent logger in at module load. Co-Authored-By: Claude Opus 5 --- .../editor-compiler-platform-port.test.ts | 71 +++++++- .../editor/compiler/compiler-module.ts | 12 ++ .../compiler/editor-compiler-platform-port.ts | 75 ++++++-- .../shared/compile/__tests__/pipeline.test.ts | 127 ++++++++++++++ src/backend/shared/compile/pipeline.ts | 67 ++++++- .../__tests__/probe-runtime-version.test.ts | 163 +++++++++++++++++- .../shared/library/probe-runtime-version.ts | 91 +++++++++- .../shared/ports/compiler-platform-port.ts | 28 +++ 8 files changed, 600 insertions(+), 34 deletions(-) diff --git a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts index 1de587f46..66b2edcda 100644 --- a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts +++ b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts @@ -277,7 +277,29 @@ describe('createEditorCompilerPlatformPort', () => { const port = createEditorCompilerPlatformPort(makeHandlers({ handleVendorPluginPackaging }), makeContext()) const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined) expect(handleVendorPluginPackaging).toHaveBeenCalledTimes(1) - expect(result).toEqual({ files: {} }) + // No `getVppRuntimeFloor` in the default context, so no floor is known — + // which the pipeline reads as "no constraint". + expect(result).toEqual({ files: {}, minRuntimeVersion: null }) + }) + + it('packageVppPlugin surfaces the VPP runtime floor when the context can resolve one', async () => { + const getVppRuntimeFloor = jest.fn(() => '4.1.9') + const port = createEditorCompilerPlatformPort(makeHandlers(), makeContext({ getVppRuntimeFloor })) + const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined) + expect(getVppRuntimeFloor).toHaveBeenCalledWith('SLM-RP4') + expect(result.minRuntimeVersion).toBe('4.1.9') + }) + + it('packageVppPlugin reports no floor when the resolver throws', async () => { + // A gate that failed the build because it could not read its own metadata + // would be worse than the mismatch it exists to catch. + const getVppRuntimeFloor = jest.fn(() => { + throw new Error('registry unreadable') + }) + const port = createEditorCompilerPlatformPort(makeHandlers(), makeContext({ getVppRuntimeFloor })) + const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined) + expect(result.minRuntimeVersion).toBeNull() + expect(result.errors).toBeUndefined() }) it('packageVppPlugin returns an errors[] when the handler throws', async () => { @@ -327,7 +349,48 @@ describe('createEditorCompilerPlatformPort', () => { { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, () => undefined, ) - expect(result).toEqual({ ok: true, version: '4.1.2' }) + // This stub answers every endpoint with a `/api/version` body, so + // `/api/capabilities` yields no usable `runtimeVersion` and the probe + // falls back — the exact shape of a runtime predating the endpoint. + expect(result).toEqual({ ok: true, version: '4.1.2', minEditorVersion: null }) + }) + + it('checkRuntimeVersion reads the editor floor from /api/capabilities when the device serves it', async () => { + const makeRuntimeApiRequest = jest.fn(async (_ip: string, endpoint: string) => { + if (endpoint === '/api/capabilities') { + return { success: true as const, data: { runtimeVersion: 'v4.2.0', minEditorVersion: '4.2.1' } } + } + return { success: true as const, data: { version: 'SHOULD-NOT-BE-USED' } } + }) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] + const port = createEditorCompilerPlatformPort( + makeHandlers(), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), + ) + const result = await port.checkRuntimeVersion( + { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, + () => undefined, + ) + expect(result).toEqual({ ok: true, version: 'v4.2.0', minEditorVersion: '4.2.1' }) + }) + + it('checkRuntimeVersion falls back to /api/version when capabilities 404s', async () => { + const makeRuntimeApiRequest = jest.fn(async (_ip: string, endpoint: string) => { + if (endpoint === '/api/capabilities') return { success: false as const, error: '404 Not Found' } + return { success: true as const, data: { version: 'v4.1.7' } } + }) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] + const log = jest.fn() + const port = createEditorCompilerPlatformPort( + makeHandlers(), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), + ) + const result = await port.checkRuntimeVersion( + { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, + log, + ) + expect(result).toEqual({ ok: true, version: 'v4.1.7', minEditorVersion: null }) + // The 404 is the normal answer from every deployed runtime — it must not + // nag the user on every upload. + expect(log).not.toHaveBeenCalled() }) it('checkRuntimeVersion returns version=null and logs a warning on probe failure', async () => { @@ -344,7 +407,7 @@ describe('createEditorCompilerPlatformPort', () => { { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, log, ) - expect(result).toEqual({ ok: true, version: null }) + expect(result).toEqual({ ok: true, version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime'), 'warning') }) @@ -361,7 +424,7 @@ describe('createEditorCompilerPlatformPort', () => { { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, log, ) - expect(result).toEqual({ ok: true, version: null }) + expect(result).toEqual({ ok: true, version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith(expect.stringContaining('probe blew up'), 'warning') }) }) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 0aaf17a3e..329c5038e 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -112,6 +112,7 @@ import { buildModuleConfigEntries, generateVendorPluginConfig, } from '@root/backend/shared/utils/vpp/generate-vendor-plugin-config' +import { APP_VERSION } from '@root/frontend/data/constants/app-version' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { app as electronApp, dialog, MessageChannelMain } from 'electron' import type { MessagePortMain } from 'electron/main' @@ -2634,6 +2635,11 @@ class CompilerModule { cleanBuild: cleanBuild ?? false, mainProcessBridge, compressSourceFolder: (folderPath: string) => this.compressSourceFolder(folderPath), + // VPP runtime floor (DOPE-448). Constructed per call rather than + // held on the class because the registry is read off disk and may + // have changed since the last compile (a package installed or + // removed mid-session). + getVppRuntimeFloor: (board: string) => new PackageManagerModule().getRuntimeFloorForBoard(board), pollTimeoutMs: CompilerModule.COMPILATION_STATUS_TIMEOUT_MS, pollIntervalMs: CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS, startTimeoutMs: POST_BUILD_START_TIMEOUT_MS, @@ -2716,6 +2722,12 @@ class CompilerModule { communicationPort: communicationPort ?? undefined, ...(vppModbusState ? { vppModbusState } : {}), vendorScreenData: effectiveVendorScreenData, + // Compared against the `minEditorVersion` a runtime publishes at + // `/api/capabilities` (DOPE-448). Injected because the pipeline + // lives in `backend/shared/`, which the layer rules keep out of + // `frontend/data/` — which build is running is a fact about the + // host app, not about the compile. + editorVersion: APP_VERSION, }, platformPort, (event) => { diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 8c78488b2..2a5e88beb 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -117,6 +117,22 @@ export interface EditorCompilerPlatformPortContext { * in the `archiver`-dependent compressSourceFolder method (which * has its own private state on CompilerModule). */ compressSourceFolder: (folderPath: string) => Promise + /** + * `package.minRuntimeVersion` of the VPP providing a given board, or + * null when the board isn't from a VPP / declares no floor + * (DOPE-448). The pipeline compares it against the connected + * runtime after the version probe. + * + * Injected rather than resolved here for the same reason as + * `compressSourceFolder`: importing `PackageManagerModule` directly + * pulls in the Electron-dependent logger at module load, which + * breaks anything importing this adapter outside a real Electron + * process (its own unit test included). + * + * Optional so callers predating this stay valid; absent means "no + * floor known", which the pipeline treats as no constraint. + */ + getVppRuntimeFloor?: (boardTarget: string) => string | null /** Timeout for the post-upload compile-status poll. */ pollTimeoutMs: number /** Interval for the post-upload compile-status poll. */ @@ -503,8 +519,14 @@ export function createEditorCompilerPlatformPort( }, /** - * Probe the device's `/api/version` (unauthenticated) so the - * pipeline can short-circuit uploads to pre-4.1.0 runtimes. + * Probe the device (unauthenticated) so the pipeline can + * short-circuit uploads in both directions: to a runtime too old + * for this editor, and from an editor too old for this runtime. + * + * Tries `/api/capabilities` first — it carries the runtime version + * AND the runtime's `minEditorVersion` in one round-trip — and + * falls back to `/api/version` for runtimes that predate it + * (DOPE-448). * * Transport: Electron's HTTPS bridge → device IP. * Response parsing + null-fallback live in the shared @@ -513,19 +535,21 @@ export function createEditorCompilerPlatformPort( */ async checkRuntimeVersion(args: CheckRuntimeVersionArgs, log: PlatformLog): Promise { const deviceContext = assertEditorHttpsContext(args.context) - const { version } = await probeRuntimeVersion({ - fetchVersion: async () => { - const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ version: string }>( - deviceContext.ip, - '/api/version', - (data: string) => JSON.parse(data) as { version: string }, - ) - if (!result.success) return { success: false, error: result.error } - return { success: true, body: result.data } - }, + const getJson = async (endpoint: string) => { + const result = await context.mainProcessBridge.makeRuntimeApiRequest( + deviceContext.ip, + endpoint, + (data: string) => JSON.parse(data) as unknown, + ) + if (!result.success) return { success: false as const, error: result.error } + return { success: true as const, body: result.data } + } + const { version, minEditorVersion } = await probeRuntimeVersion({ + fetchCapabilities: () => getJson('/api/capabilities'), + fetchVersion: () => getJson('/api/version'), log, }) - return { ok: true, version } + return { ok: true, version, minEditorVersion } }, /** @@ -568,7 +592,12 @@ export function createEditorCompilerPlatformPort( log(message, logLevel ?? 'info') }, ) - return { files: {} } + // Surface the package's runtime floor so the pipeline can compare + // it against the connected runtime after the version probe + // (DOPE-448). Read here rather than inside the handler because + // the handler returns void and writes straight to disk; the + // registry lookup is cheap next to the packaging work that ran. + return { files: {}, minRuntimeVersion: readVppRuntimeFloor(context, args.boardTarget) } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { @@ -598,6 +627,24 @@ export function assertEditorHttpsContext( return context } +/** + * `package.minRuntimeVersion` of the VPP providing `boardTarget`, or + * null when no resolver was injected, the board is not from a VPP, is + * not a `runtime-v4` target, or the package declares no floor. + * + * Never throws: a missing or unreadable registry means "no declared + * floor", which the pipeline treats as no constraint. A version gate + * that failed the build because it could not read its own metadata + * would be worse than the mismatch it exists to catch. + */ +function readVppRuntimeFloor(context: EditorCompilerPlatformPortContext, boardTarget: string): string | null { + try { + return context.getVppRuntimeFloor?.(boardTarget) ?? null + } catch { + return null + } +} + /** * Find the arduino-cli-produced `Baremetal.ino.hex` under the build * directory. arduino-cli writes it to a board-FQBN-specific diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index 06f2f3353..169789e12 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -40,6 +40,17 @@ jest.mock('../../firmware/runtime-version-gate', () => ({ describeIncompatibleRuntime: jest.fn( (v: string | null) => `Runtime ${String(v)} is too old; please upgrade to 4.1.0+.`, ), + // The two DOPE-448 message builders. Only the message text is stubbed — + // the *decisions* are made by `isVersionAtLeast` from + // `frontend/utils/semver`, which is deliberately NOT mocked so these tests + // exercise the real comparison. + describeEditorTooOldForRuntime: jest.fn( + (a: { minEditorVersion: string }) => `This editor is older than the runtime requires (${a.minEditorVersion}).`, + ), + describeVppRuntimeMismatch: jest.fn( + (a: { boardTarget: string; minRuntimeVersion: string }) => + `Board "${a.boardTarget}" needs runtime ${a.minRuntimeVersion} or newer.`, + ), })) // Mock the conf-generator step so tests can deterministically force // the runtime-v4 confs branch to throw (covers the pipeline's outer @@ -379,6 +390,122 @@ describe('runCompilePipeline — runtime v4 path', () => { expect(events.some((e) => /too old|upgrade/i.test(e.message))).toBe(true) }) + // ------------------------------------------------------------------------- + // DOPE-448: the two floors the runtime and the VPP declare + // ------------------------------------------------------------------------- + + const v4Args = (overrides: Partial = {}) => + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + // The host app injects its own version; `4.2.0` is low enough to be + // refused by the floors below and high enough to clear the passing ones. + editorVersion: '4.2.0', + ...overrides, + }) + + it('aborts when the runtime declares a minEditorVersion above this editor', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ + ok: true, + version: 'v4.2.0', + minEditorVersion: '4.2.1', + }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(false) + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + expect(events.some((e) => /older than the runtime requires \(4\.2\.1\)/.test(e.message))).toBe(true) + }) + + // The second, independent way the gate stays inert: a caller that never + // opts in. Web passes no `editorVersion` until its adapter wires one up, + // and must keep uploading. + it('uploads when the caller passes no editorVersion, even against a declared floor', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.2.0', minEditorVersion: '99.0.0' }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args({ editorVersion: undefined }), port, emit) + + expect(result.success).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + + // This is every runtime currently in the field: it answers /api/version + // only, so no floor reaches the pipeline. The gate must be completely + // inert for them — that is what makes shipping this safe. + const NO_FLOOR_DECLARED: Array<[string | null | undefined, string]> = [ + [undefined, 'the field is absent (runtime predates /api/capabilities)'], + [null, 'the runtime declares no floor'], + ] + + it.each(NO_FLOOR_DECLARED)('uploads normally when minEditorVersion is %p — %s', async (minEditorVersion) => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.1.7', minEditorVersion }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(true) + expect(result.uploaded).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + + it('uploads when this editor satisfies the runtime-declared floor', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.2.0', minEditorVersion: '1.0.0' }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + + it('aborts when the VPP requires a newer runtime than the device reports', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.1.7' }), + packageVppPlugin: jest.fn().mockResolvedValue({ files: {}, minRuntimeVersion: '4.1.9' }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(false) + // Blocked BEFORE the upload: the failure this prevents is a vendor plugin + // that loads on a live PLC and dies at scan time. + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + expect(events.some((e) => /needs runtime 4\.1\.9 or newer/.test(e.message))).toBe(true) + }) + + it('uploads when the runtime satisfies the VPP floor', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.2.0' }), + packageVppPlugin: jest.fn().mockResolvedValue({ files: {}, minRuntimeVersion: '4.1.9' }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + + it('uploads when the board is not from a VPP (no floor declared)', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.1.7' }), + packageVppPlugin: jest.fn().mockResolvedValue({ files: {}, minRuntimeVersion: null }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + it('compileOnly on v4 returns success without invoking checkRuntimeVersion or uploadRuntimeV4', async () => { const port = makePort() const { emit } = captureEvents() diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index c3922edf1..1944d15bb 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -20,6 +20,7 @@ * `emit` callback (progress events). No disk I/O, no globals. */ +import { isVersionAtLeast } from '../../../frontend/utils/semver' import type { CompilerPlatformPort, PlatformDeviceContext, @@ -30,7 +31,12 @@ import { composeRuntimeV4Bundle } from '../../../middleware/shared/utils/library import { resolveTargetCapabilities } from '../../../middleware/shared/utils/target-capabilities' import type { BoardHalsCompileEntry } from '../firmware/build-arduino-cli-args' import { buildArduinoCliCompileArgs } from '../firmware/build-arduino-cli-args' -import { describeIncompatibleRuntime, isStrucppCompatibleRuntime } from '../firmware/runtime-version-gate' +import { + describeEditorTooOldForRuntime, + describeIncompatibleRuntime, + describeVppRuntimeMismatch, + isStrucppCompatibleRuntime, +} from '../firmware/runtime-version-gate' import { buildKnownPous, emitCompileErrorEvents } from '../library/program-build-helpers' import { runProgramBuildPipeline } from '../library/program-build-pipeline' import type { DevicePin } from '../types/PLC/devices' @@ -211,6 +217,19 @@ export interface RunCompilePipelineArgs { * addresses without re-reading the file. Called once per * successful strucpp compile. */ cacheDebugData?: (md5: string, debugMapJson: string) => void + /** + * This editor's own version, compared against the `minEditorVersion` + * a runtime publishes at `GET /api/capabilities` (DOPE-448). + * + * Injected rather than imported: `APP_VERSION` lives in + * `frontend/data/`, and the layer rules forbid `backend/shared/` + * from reaching into `data` — correctly, since which build is + * running is a fact about the host app, not about the compile. + * + * Absent means the caller opts out of the check, so the gate is + * inert for callers written before it existed. + */ + editorVersion?: string /** Persisted VPP Modbus screen state for the target device, * sourced from `DeviceConfiguration.vendorScreenData` under * the `modbus_rtu` / `modbus_tcp` keys. Threaded straight @@ -353,6 +372,7 @@ async function runCompilePipelineInner( cacheDebugData, vppModbusState, vendorScreenData, + editorVersion, } = args // Resolve the board's effective capabilities from `boardEntry`. @@ -588,6 +608,51 @@ async function runCompilePipelineInner( return bailError(emit, 'runtime-version', describeIncompatibleRuntime(versionCheck.version)) } + // The other direction (DOPE-448): the runtime published a + // `minEditorVersion` at `/api/capabilities` and this editor is below + // it. Inert in two independent ways, both of which describe the + // world as it is today: `versionCheck.minEditorVersion` is null for + // every runtime predating that endpoint, and `editorVersion` is + // absent for any caller that hasn't opted in — `isVersionAtLeast` + // passes on an absent floor either way. + if (editorVersion && !isVersionAtLeast(editorVersion, versionCheck.minEditorVersion)) { + return bailError( + emit, + 'runtime-version', + describeEditorTooOldForRuntime({ + runtimeVersion: versionCheck.version, + // Narrowed by the guard above: `isVersionAtLeast` only returns + // false when it parsed a real floor out of this field. + minEditorVersion: versionCheck.minEditorVersion ?? '', + editorVersion, + // Only the editor's direct-HTTPS context knows an address the + // user would recognise; on web the device sits behind an + // orchestrator agent, so the message omits the label rather + // than printing an agent id nobody can act on. + deviceLabel: deviceContext.kind === 'editor-https' ? deviceContext.ip : undefined, + }), + ) + } + + // Arc 4 (DOPE-448): the VPP whose HAL is about to be built on this + // device declares a runtime floor, and this runtime is below it. + // Checked here rather than at install time because the target device + // is unknown until the user connects to one — and checked BEFORE the + // upload, because the failure mode it prevents is a plugin that + // loads on a live PLC and dies at scan time. + if (!isVersionAtLeast(versionCheck.version, vppResult.minRuntimeVersion)) { + return bailError( + emit, + 'runtime-version', + describeVppRuntimeMismatch({ + boardTarget, + minRuntimeVersion: vppResult.minRuntimeVersion ?? '', + runtimeVersion: versionCheck.version, + deviceLabel: deviceContext.kind === 'editor-https' ? deviceContext.ip : undefined, + }), + ) + } + emit({ stage: 'upload', message: 'Uploading Runtime v4 bundle...', level: 'info' }) const uploadResult = await port.uploadRuntimeV4({ bundle, context: deviceContext }, makePlatformLog(emit, 'upload')) if (!uploadResult.ok) { diff --git a/src/backend/shared/library/__tests__/probe-runtime-version.test.ts b/src/backend/shared/library/__tests__/probe-runtime-version.test.ts index e090b5c77..27030b35a 100644 --- a/src/backend/shared/library/__tests__/probe-runtime-version.test.ts +++ b/src/backend/shared/library/__tests__/probe-runtime-version.test.ts @@ -16,7 +16,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: { version: '4.1.2' } }), log, }) - expect(result).toEqual({ version: '4.1.2' }) + expect(result).toEqual({ version: '4.1.2', minEditorVersion: null }) expect(log).not.toHaveBeenCalled() }) @@ -26,7 +26,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: { version: '4.0.5' } }), log, }) - expect(result).toEqual({ version: '4.0.5' }) + expect(result).toEqual({ version: '4.0.5', minEditorVersion: null }) }) it('returns version=null and logs a warning when the transport fails', async () => { @@ -35,7 +35,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: false, error: 'ECONNREFUSED' }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime: ECONNREFUSED'), 'warning') }) @@ -47,7 +47,7 @@ describe('probeRuntimeVersion', () => { }, log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith( expect.stringContaining('Runtime version probe failed: orchestrator HTTP down'), 'warning', @@ -63,7 +63,7 @@ describe('probeRuntimeVersion', () => { }, log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith(expect.stringContaining('plain string failure'), 'warning') }) @@ -73,7 +73,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: { otherField: 'noise' } }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) expect(log).not.toHaveBeenCalled() }) @@ -83,7 +83,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: { version: 4 } }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) }) it('returns version=null when the body is null', async () => { @@ -92,7 +92,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: null }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) }) it('returns version=null when the body is a primitive (not an object)', async () => { @@ -101,6 +101,151 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: 'a string' }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) + }) +}) + +// --------------------------------------------------------------------------- +// /api/capabilities (DOPE-448) +// --------------------------------------------------------------------------- + +describe('probeRuntimeVersion — capabilities endpoint', () => { + /** A `fetchVersion` that fails the test if the fallback is reached. */ + const versionMustNotBeCalled = () => { + const spy = jest.fn(async () => ({ success: true as const, body: { version: 'FALLBACK' } })) + return spy + } + + it('prefers the capabilities endpoint and reads both fields from it', async () => { + const log = jest.fn() + const fetchVersion = versionMustNotBeCalled() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ + success: true, + body: { runtimeVersion: 'v4.2.0', minEditorVersion: '4.2.1' }, + }), + fetchVersion, + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion: '4.2.1' }) + // One round-trip, not two: the capabilities answer is complete. + expect(fetchVersion).not.toHaveBeenCalled() + expect(log).not.toHaveBeenCalled() + }) + + it('reports minEditorVersion=null when the endpoint answers without that field', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: true, body: { runtimeVersion: 'v4.2.0' } }), + fetchVersion: versionMustNotBeCalled(), + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion: null }) + }) + + it('ignores a non-string minEditorVersion rather than passing it to a comparison', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ + success: true, + body: { runtimeVersion: 'v4.2.0', minEditorVersion: 421 }, + }), + fetchVersion: versionMustNotBeCalled(), + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion: null }) + }) + + // The single most important case: this is every runtime currently + // deployed. The fallback must be silent and unremarkable — if it warned, + // every existing device would nag on every upload. + // + // Note the 401: a pre-DOPE-448 runtime does NOT answer 404 for an unknown + // path. Its `restapi.py` ends in a catch-all `/` route guarded by + // `@jwt_required()`, so `/api/capabilities` lands there and comes back as + // "Missing Authorization Header". Observed against a real container — the + // 404 row is kept because a future runtime could answer either way. + const LEGACY_RESPONSES: Array<[string, string]> = [ + ['401 Missing Authorization Header', 'the / catch-all swallows the unknown path'], + ['404 Not Found', 'a runtime that routes unknown paths properly'], + ] + + it.each(LEGACY_RESPONSES)('falls back to /api/version on %p (%s), without warning', async (error) => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: false, error }), + fetchVersion: async () => ({ success: true, body: { version: 'v4.1.7' } }), + log, + }) + expect(result).toEqual({ version: 'v4.1.7', minEditorVersion: null }) + expect(log).not.toHaveBeenCalled() + }) + + // Belt and braces: if a transport surfaces the 401 as a *successful* fetch + // carrying the error body (rather than as a failure), the probe must still + // fall back — there is no `runtimeVersion` to read out of it. + it('falls back when the 401 body arrives as a successful fetch', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: true, body: { msg: 'Missing Authorization Header' } }), + fetchVersion: async () => ({ success: true, body: { version: 'v4.1.7' } }), + log, + }) + expect(result).toEqual({ version: 'v4.1.7', minEditorVersion: null }) + expect(log).not.toHaveBeenCalled() + }) + + it('falls back when the capabilities transport throws', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => { + throw new Error('TLS handshake failed') + }, + fetchVersion: async () => ({ success: true, body: { version: 'v4.1.7' } }), + log, + }) + expect(result).toEqual({ version: 'v4.1.7', minEditorVersion: null }) + expect(log).not.toHaveBeenCalled() + }) + + // A partial answer is rejected wholesale: if we cannot read the version out + // of this response we do not trust the minEditorVersion beside it either. + const UNUSABLE_BODIES: Array<[unknown, string]> = [ + [{ minEditorVersion: '4.2.1' }, 'runtimeVersion is missing'], + [{ runtimeVersion: 42, minEditorVersion: '4.2.1' }, 'runtimeVersion is not a string'], + [null, 'the body is null'], + ['a string', 'the body is a primitive'], + ] + + it.each(UNUSABLE_BODIES)('falls back when %j (%s)', async (body) => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: true, body }), + fetchVersion: async () => ({ success: true, body: { version: 'v4.1.7' } }), + log, + }) + expect(result).toEqual({ version: 'v4.1.7', minEditorVersion: null }) + }) + + it('behaves exactly as before when no capabilities transport is wired', async () => { + // Platforms that have not adopted the endpoint yet (and every caller + // written before it existed) keep their previous behaviour. + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: true, body: { version: '4.1.2' } }), + log, + }) + expect(result).toEqual({ version: '4.1.2', minEditorVersion: null }) + }) + + it('still warns about an unreachable device when the fallback also fails', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: false, error: '404 Not Found' }), + fetchVersion: async () => ({ success: false, error: 'ECONNREFUSED' }), + log, + }) + expect(result).toEqual({ version: null, minEditorVersion: null }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime: ECONNREFUSED'), 'warning') }) }) diff --git a/src/backend/shared/library/probe-runtime-version.ts b/src/backend/shared/library/probe-runtime-version.ts index ecf1b3cfc..1190265c0 100644 --- a/src/backend/shared/library/probe-runtime-version.ts +++ b/src/backend/shared/library/probe-runtime-version.ts @@ -50,6 +50,25 @@ export interface ProbeRuntimeVersionOptions { * hit the device's `/api/version`; web POSTs to the * orchestrator's `run-command` with `api: 'api/version'`. */ fetchVersion(): Promise + /** + * Transport callback for `GET /api/capabilities` — the endpoint + * where a runtime declares what it requires of an editor + * (DOPE-448). Optional: a platform that hasn't wired it up yet + * behaves exactly as before. + * + * A runtime predating the endpoint does NOT answer 404. Its + * `restapi.py` ends in a catch-all `@restapi_bp.route("/")` + * guarded by `@jwt_required()`, so an unknown path under `/api/` + * falls into it and comes back as **401 Missing Authorization + * Header** — verified against a real pre-DOPE-448 container. Both + * outcomes land here the same way (a failed fetch, or a body with + * no `runtimeVersion`), which is why this probe keys off "can I + * read a version out of the answer" rather than off a status code. + * + * Either way `minEditorVersion` comes back `null`, meaning "this + * runtime declares no floor" — never "the editor is too old". + */ + fetchCapabilities?(): Promise /** Warning channel for diagnostics the user can see in the * compile console (e.g. "Could not reach runtime: ECONNREFUSED"). * Wired to the platform port's `log` callback by the caller so @@ -62,24 +81,74 @@ export interface ProbeRuntimeVersionResult { * when the probe couldn't extract one. The shared compile * pipeline feeds this verbatim to `isStrucppCompatibleRuntime`. */ version: string | null + /** + * The oldest editor this runtime accepts programs from, as + * declared at `GET /api/capabilities`, or `null` when the runtime + * declares nothing — it predates the endpoint, the platform has no + * transport for it, or the field was missing/malformed. + * + * `null` must be treated as "no constraint", not as a failure: it + * is the state of every runtime currently in the field, and the + * whole point of the runtime advertising rather than enforcing is + * that shipping this can't lock those out. + */ + minEditorVersion: string | null } /** * Run the probe. Always resolves — never throws — so the pipeline * gets a deterministic answer it can branch on. + * + * `/api/capabilities` is preferred where available because it carries + * both halves of the compatibility question in one round-trip, and it + * reports the version under `runtimeVersion` rather than `version`. + * When it is absent or unusable the probe falls back to + * `/api/version`, which every runtime has. */ export async function probeRuntimeVersion(opts: ProbeRuntimeVersionOptions): Promise { + const capabilities = await tryFetchCapabilities(opts) + if (capabilities) return capabilities + try { const result = await opts.fetchVersion() if (!result.success) { opts.log(`Could not reach runtime: ${result.error}`, 'warning') - return { version: null } + return { version: null, minEditorVersion: null } } - return { version: extractVersionFromBody(result.body) } + return { version: extractVersionFromBody(result.body), minEditorVersion: null } } catch (error) { const message = error instanceof Error ? error.message : String(error) opts.log(`Runtime version probe failed: ${message}`, 'warning') - return { version: null } + return { version: null, minEditorVersion: null } + } +} + +/** + * Attempt the capabilities endpoint. Returns null — meaning "fall + * back to /api/version" — for every unusable outcome: no transport + * wired, a 404 from a runtime that predates the endpoint, a thrown + * transport error, or a body with no usable `runtimeVersion`. + * + * A partial answer is deliberately not accepted. If we cannot read + * the version out of this response we do not trust the + * `minEditorVersion` beside it either, and `/api/version` is the + * authority on the version anyway. + */ +async function tryFetchCapabilities(opts: ProbeRuntimeVersionOptions): Promise { + if (!opts.fetchCapabilities) return null + try { + const result = await opts.fetchCapabilities() + if (!result.success) { + // Expected against every runtime older than the endpoint — in + // practice a 401 from the `/` catch-all, not a 404 — so + // this is an ordinary fact, not a problem worth a warning. + return null + } + const version = extractStringField(result.body, 'runtimeVersion') + if (version === null) return null + return { version, minEditorVersion: extractStringField(result.body, 'minEditorVersion') } + } catch { + return null } } @@ -91,8 +160,18 @@ export async function probeRuntimeVersion(opts: ProbeRuntimeVersionOptions): Pro * answer as incompatible. */ function extractVersionFromBody(body: unknown): string | null { + return extractStringField(body, 'version') +} + +/** + * Read a top-level string field out of a response body, collapsing + * every other shape (not an object, field absent, field not a string) + * to `null` so callers get one "unknown" value to branch on instead of + * having to distinguish the ways a body can disappoint them. + */ +function extractStringField(body: unknown, field: string): string | null { if (typeof body !== 'object' || body === null) return null - if (!('version' in body)) return null - const v = (body as { version: unknown }).version - return typeof v === 'string' ? v : null + if (!(field in body)) return null + const value = (body as Record)[field] + return typeof value === 'string' ? value : null } diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index c7698ac92..cc13ae2fa 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -236,6 +236,17 @@ export interface CheckRuntimeVersionResult { * when the runtime is unreachable or doesn't expose the * endpoint (very old v3 runtimes). */ version: string | null + /** + * Oldest editor this runtime accepts programs from, declared at + * `GET /api/capabilities` (DOPE-448). `null` means the runtime + * declares no floor — it predates the endpoint, or this platform + * has no transport for it. + * + * `null` is "no constraint", never "too old": every runtime + * currently deployed answers `null`, and the runtime only + * advertises this value — the editor is what compares and refuses. + */ + minEditorVersion?: string | null } /** VPP (Vendor Plugin Package) runtime-v4 packaging. Boards that @@ -263,6 +274,23 @@ export interface PackageVppPluginResult { * and return an empty record without errors. */ files: Record errors?: StructuredCompileError[] + /** + * `package.minRuntimeVersion` from the manifest of the VPP this + * board came from (DOPE-448) — the oldest runtime whose plugin API + * the package's HAL was built against. + * + * `null`/absent for non-VPP boards, for packages that declare no + * floor, and on platforms without VPP integration. The pipeline + * compares it against the connected runtime's reported version + * right after the version probe, which is the earliest point where + * both halves are known — a VPP plugin is built against a runtime + * API, so an older runtime loads it and fails at scan time, on a + * live PLC. + * + * This cannot be enforced at install time: the target device is + * unknown until the user connects to one. + */ + minRuntimeVersion?: string | null } // --------------------------------------------------------------------------- From 8e0898b1b6dd227c1f8e2bca487b88df2cbee786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 6 Aug 2026 10:57:17 +0200 Subject: [PATCH 61/79] docs(compat): record the editor/runtime/VPP compatibility strategy (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three artefacts on independent release cadences, five arcs that can be mismatched in both directions, and four disconnected checks that were each invented separately. Documents the agreed design — each component declares a minimum, the editor is the only component that compares — plus what exists today with file:line references, the error-message rules, and the delivery phases. Section 9 records what was considered and dropped, so nobody re-derives it: monotonic integer contracts, a bundle-manifest.json inside the upload ZIP, an editor->runtime advertising handshake, and max* bounds (an upper bound naming releases that do not exist yet is unknowable). Section 7 records the accepted limitation in its own section: the runtime advertises rather than enforces, so a client that skips the check can still upload. Co-Authored-By: Claude Opus 5 --- docs/version-compatibility-strategy.md | 350 +++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 docs/version-compatibility-strategy.md diff --git a/docs/version-compatibility-strategy.md b/docs/version-compatibility-strategy.md new file mode 100644 index 000000000..f5495657f --- /dev/null +++ b/docs/version-compatibility-strategy.md @@ -0,0 +1,350 @@ +# Editor + Runtime + VPP Version Compatibility — Strategy + +> Status: **agreed design** (Marcone + Thiago Alves, 2026-08-05) +> Jira: [DOPE-448](https://autonomylogic.atlassian.net/browse/DOPE-448) (epic DOPE-317 — VPP in the Editor) +> Scope: `openplc-editor` + `openplc-web` (shared surface — byte-identical), +> `openplc-runtime`, `openplc-packages` + +## 1. The decision + +Three declarations, one comparer. + +| Component | Declares | Where it lives | +| ----------- | ---------------------------------------- | ---------------------------------------------- | +| **VPP** | `minEditorVersion` + `minRuntimeVersion` | `manifest.json` → `package` (openplc-packages) | +| **Runtime** | `minEditorVersion` | new `GET /api/capabilities` (openplc-runtime) | +| **Editor** | `minRuntimeVersion` | global constant in the shared surface | + +**The editor is the only component that compares.** The runtime publishes what it +requires and never enforces it; there is no editor→runtime advertising handshake +and nothing new travels inside the upload bundle. + +Two consequences of that choice, both good: + +- **No release-ordering constraint between phases.** A runtime that starts + publishing `minEditorVersion` changes nothing for editors that don't read it + yet. Every phase below is independently shippable in any order, and none can + break a device already in the field. +- **One place to debug.** When an upload is refused, the decision was made in the + editor, with both version strings in hand. + +The accepted trade-off: the runtime _advertises_ rather than _enforces_, so a +client that skips the check can still upload. This protects the real case (our +editor against our runtime) and is not a security boundary — see §7. + +## 2. What is missing today, per declaration + +### 2.1 VPP — the field exists, nothing checks it + +`schema/manifest.schema.json` already makes `package.minEditorVersion` +**required**, and `openplc-packages/docs/package-format.md:69` already promises: + +> `minEditorVersion`: Editor will refuse to install packages requiring a newer version. + +**That promise is not implemented.** The only consumer is the remote catalog UI — +`catalog-browser.tsx:272` uses `isCompatibleEditorVersion(v.minEditorVersion, APP_VERSION)` +to render an "Editor outdated" button state. + +`package-manager-module.ts::install` — the single trust boundary that _both_ the +remote install and the local "Add from file…" flow pass through, which already +validates the manifest schema, verifies the package signature and hardens the +path — **never looks at `minEditorVersion`**. So today: + +- installing a `.vpp` from disk ignores it entirely; +- a package installed before an editor downgrade keeps loading; +- compiling against an incompatible package is never blocked. + +This matters more than a missing field would, because the agreed design _relies_ +on this mechanism: a VPP that needs a UI engine only present from editor 4.2.1 +onward is expected to be unable to install on 4.2.0. Making that true is the +first piece of work. + +`minRuntimeVersion` does not exist in the schema at all. It is needed because a +`runtime-v4-plugin` HAL is code that runs **inside the runtime process** — +`apply_vpp_plugin_conf()` installs its conf on every upload — so a package built +against a newer runtime plugin API currently installs cleanly and fails at load +or scan time. + +### 2.2 Runtime — publishes a version, requires nothing + +`webserver/version.py` resolves `RUNTIME_VERSION` (baked in at image build time +via `ARG RUNTIME_VERSION`), exposed at `GET /api/version` and on the +`X-OpenPLC-Runtime-Version` header (`webserver/restapi.py:46`). + +There is no endpoint where the runtime states what it needs from an editor. +`handle_upload_file` accepts any ZIP that passes `analyze_zip` (path traversal, +size, ZIP-bomb ratio, denylisted executable extensions). + +### 2.3 Editor — the constant exists under a narrower name + +`src/backend/shared/firmware/runtime-version-gate.ts` (shared surface) already +holds the editor's floor: + +```ts +export const MIN_STRUCPP_RUNTIME_VERSION = '4.1.0' +``` + +This _is_ the global `minRuntimeVersion` the design calls for — it blocks upload +to any runtime below it. It only needs a name that says so. + +**It must not absorb the per-feature gates.** The same file also holds: + +```ts +export const MIN_USER_MANAGEMENT_RUNTIME_VERSION = '4.1.9' +``` + +That one hides a UI screen; it does not block upload. Collapsing the two into one +number forces a bad choice: declare `4.1.0` and the User Management screen breaks +on a 4.1.7 runtime; declare `4.1.9` and upload is blocked on a 4.1.7 runtime that +handles upload perfectly. They stay separate. + +### 2.4 Two semver parsers with divergent semantics + +The whole design rests on comparing version strings, and the codebase currently +answers "is X at least Y" two different ways: + +| | `frontend/utils/semver.ts` | `firmware/runtime-version-gate.ts` | +| ----------------------- | -------------------------- | ---------------------------------- | +| Consumers | VPP catalog | runtime gates | +| `"v4"` | `4.0.0` | `null` (rejected) | +| `"4.1"` | `4.1.0` | `null` (rejected) | +| `"garbage"` | `0.0.0` (lowest) | `null` (rejected) | +| `4.1.0-rc.3` vs `4.1.0` | equal (suffix stripped) | equal, **deliberately** | +| Failure mode | degrade to lowest | fail closed | + +Both are individually well-reasoned. Together they mean the three comparisons in +§1 could disagree depending on which helper a call site happened to import — +exactly on the malformed and pre-release inputs that show up in the field. +Unifying them is cheap and is a precondition for everything else. + +## 3. How it works + +### 3.1 Runtime → Editor + +Scenario: editor **4.2.10**, Raspberry Pi at **192.168.1.50** running runtime **4.2.0**. + +``` +GET http://192.168.1.50/api/capabilities +``` + +```json +{ + "runtimeVersion": "v4.2.0", + "minEditorVersion": "4.2.1" +} +``` + +The editor compares, both directions, locally: + +``` +runtime 4.2.0 >= editor's MIN_RUNTIME_VERSION (4.1.0)? yes → ok +editor 4.2.10 >= runtime's minEditorVersion (4.2.1)? yes → ok +→ upload proceeds +``` + +Reverse case — editor **4.2.0** against the same runtime: + +``` +editor 4.2.0 >= runtime's minEditorVersion (4.2.1)? NO → blocked +``` + +Nothing is sent. This is the "and vice-versa" direction from the card, and it is +what does not exist today. + +**Legacy runtime** — `GET /api/capabilities` returns `404`: + +``` +GET /api/version → {"version": "v4.1.7"} + +runtime declares no floor → nothing to check in that direction +runtime 4.1.7 >= MIN_RUNTIME_VERSION (4.1.0)? yes → upload proceeds +user-management needs 4.1.9? no → screen hidden +``` + +Identical to today's behaviour, plus one console warning that the runtime does +not publish its requirements. + +### 3.2 VPP → Editor, at install time + +```json +{ + "package": { + "id": "com.automationdirect.p1am", + "version": "2.0.0", + "minEditorVersion": "4.2.1", + "minRuntimeVersion": "4.1.9" + } +} +``` + +`package-manager-module.ts::install`, right after the signature check: + +``` +manifest schema valid? ok +signature verified? ok +package.id safe as a path component? ok +APP_VERSION (4.2.0) >= minEditorVersion (4.2.1)? NO → install rejected +``` + +Covers both entry paths — remote catalog install and local "Add from file…" — +because both converge here. `catalog-browser.tsx` keeps its "Editor outdated" +state but derives it from the shared helper rather than owning the decision. + +This is the mechanism that answers "what about a VPP that needs a UI engine the +editor may not have": the engine landed in some editor release, the package +declares that release as its floor, and an older editor cannot install it. + +### 3.3 VPP → Runtime, at compile time + +`minRuntimeVersion` cannot be checked at install — the target runtime is unknown +until you connect to a device. So it is checked in the compile pipeline, when the +selected board comes from a VPP whose target type is `runtime-v4`: + +``` +VPP requires runtime >= 4.1.9 +connected runtime reports v4.1.7 +→ compile blocked +``` + +## 4. Where each check lives + +| Check | Gate | Failure surface | +| ---------------------------------- | ----------------------------------------------------------------- | ---------------------------------- | +| runtime new enough for this editor | `probe-runtime-version.ts` + `MIN_RUNTIME_VERSION` | upload blocked pre-compile | +| editor new enough for this runtime | `probe-runtime-version.ts` + `minEditorVersion` from the endpoint | upload blocked pre-compile | +| editor new enough for this VPP | `package-manager-module.ts::install` (+ on load) | install rejected; package unusable | +| runtime new enough for this VPP | compile pipeline, `runtime-v4` targets | compile blocked | +| per-feature runtime capability | existing predicates in `runtime-version-gate.ts` | UI surface hidden | + +Every row is decided in the editor. The runtime and the VPP only declare. + +## 5. Error messages + +A gate that fires is a support ticket unless the message is complete. Following +the existing `describeIncompatibleRuntime`, every rejection names **what** was +refused, **which two versions** disagree, **which side** is stale, and **the one +action** that fixes it. + +``` +Runtime v4.2.0 requires OpenPLC Editor 4.2.1 or newer. +This editor is 4.2.0. +Update the editor, or connect to a runtime that accepts 4.2.0. +``` + +``` +Package "AutomationDirect P1AM" 2.0.0 requires OpenPLC Editor 4.2.1 or newer. +This editor is 4.2.0. +Update the editor, or install package version 1.4.2. +``` + +``` +Package "AutomationDirect P1AM" 2.0.0 requires runtime v4.1.9 or newer. +The runtime at 192.168.1.50 reports v4.1.7. +Upgrade the runtime on that device. +``` + +Rules: always name the device when one is involved; always state the direction — +never "incompatible versions". + +## 6. Peers that declare nothing + +Everything already in the field predates this work, so absence must be a +supported state rather than an error. + +| Peer state | Behaviour | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Runtime without `/api/capabilities` | no editor floor to check; existing `MIN_RUNTIME_VERSION` gate still applies; console warning | +| Runtime version unparseable (`"v4"`, `"dev"`) | fail closed — current behaviour, unchanged | +| VPP without `minRuntimeVersion` on a `runtime-v4` target | install allowed, runtime match unverifiable, console warning at install | + +Because the runtime never enforces, no closing window is needed on the upload +path: an old runtime simply contributes no constraint. The only future tightening +worth scheduling is making `minRuntimeVersion` mandatory for `runtime-v4` +packages, which is enforced at **package build time** (`scripts/validate.ts`) and +so never breaks an installed package. + +## 7. Accepted limitation + +The runtime advertises its requirement; it does not enforce it. A client that +does not implement the check — an older editor, or any other tool speaking the +upload API — can still push a program. + +This was decided deliberately: it keeps the upload path untouched, requires no +new metadata inside the bundle, and removes any possibility of a runtime release +locking out editors already installed. It is not a security control, and should +not be described as one. If enforcement is ever needed, the natural follow-up is +for the editor to send its version on upload and for the runtime to compare — +additive, and not required by anything in this plan. + +## 8. Delivery phases + +No ordering constraint between phases: the runtime never blocks, so a phase +shipping early cannot break a peer. Ordered by value delivered. + +**Phase 1 — one semver parser.** Unify `frontend/utils/semver.ts` and +`parseRuntimeVersion` into a single shared helper with one explicit policy for +malformed and pre-release inputs. Existing call sites keep their current +functions as thin wrappers so no behaviour changes and existing tests pass +untouched. Precondition for phases 2–4, which all compare version strings. +_Repos: editor + web (byte-identical) · ~half a day._ + +**Phase 2 — VPP install gate.** `package-manager-module.ts::install` checks +`minEditorVersion` against `APP_VERSION`, next to the signature verification; +same check when loading an already-installed package. `catalog-browser.tsx` +derives its state from the shared helper. Correct +`openplc-packages/docs/package-format.md:69` — it becomes true. +_Repos: editor + web · ~1 day._ + +**Phase 3 — `minRuntimeVersion` in the manifest.** Add the field to +`schema/manifest.schema.json`, conditionally required when any device targets +`runtime-v4`; enforce in `scripts/validate.ts` so a malformed package never +reaches a user; check it in the compile pipeline against the connected runtime. +_Repos: packages + editor + web · ~1 day._ + +**Phase 4 — `GET /api/capabilities`.** New unauthenticated endpoint returning +`runtimeVersion` + `minEditorVersion`, with the constant living beside +`RUNTIME_VERSION` in `webserver/version.py`. +_Repo: runtime · ~half a day._ + +**Phase 5 — editor consumes it.** `probe-runtime-version.ts` reads the endpoint, +treats `404` as "declares nothing", and blocks upload when +`APP_VERSION < minEditorVersion`. Rename `MIN_STRUCPP_RUNTIME_VERSION` to +`MIN_RUNTIME_VERSION` (keeping the old name as an alias) so the global reads as +what it is. +_Repos: editor + web · ~1 day._ + +**Phase 6 — tests.** Table of `(editorVersion, runtimeVersion, vppManifest) → +allow | block | warn` covering each of the four comparisons plus the +declares-nothing and unparseable rows. +_Repos: editor + web · ~half a day._ + +Total: **~4,5 days**. + +## 9. Explicitly out of scope + +Considered and dropped, so nobody re-derives them: + +- **Monotonic integer contracts** (`PROGRAM_CONTRACT_VERSION` and friends). + Would decouple the release trains and isolate a debug-protocol change from the + upload path, at the cost of a second versioning concept to maintain. Human + semver is the agreed key. +- **`bundle-manifest.json` inside the upload ZIP.** Not needed once the runtime + publishes its floor and the editor compares locally. +- **Editor→runtime advertising handshake** (`/api/adv` or similar). Decided + against: the editor validates. +- **`maxEditorVersion` / `maxRuntimeVersion`.** An upper bound pointing at + releases that do not exist yet is unknowable — any value either blocks + compatible future peers or does nothing. + +## 10. Open questions + +1. **What value does the runtime publish as `minEditorVersion` today?** Needs a + concrete audit of when the current bundle layout stabilised. Publishing a + floor that is too high locks out working editors; too low makes the field + decorative. Safest start is the oldest editor known to work with the strucpp + pipeline. +2. **Does an installed-but-incompatible VPP get hidden or shown-as-unusable?** + Hiding is cleaner; showing explains why a board disappeared after an editor + downgrade. +3. **Warning surface for peers that declare nothing** (§6) — console only, or a + one-time notice in the UI? From 23762be3ec6eb18b4bbcd04b4f4903135cf1dada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 6 Aug 2026 16:46:31 +0200 Subject: [PATCH 62/79] docs(compat): mark the strategy as shipped, not planned (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #993 caught that the doc reads as a roadmap for work this PR already contains: §2.2 stated there is no endpoint where the runtime declares what it needs, and §8 listed phases 1-6 with day estimates. A maintainer opening this in three months could reasonably redo or revert what already exists. - Header states "implemented", lists the four shipping PRs, and tells the reader §2 is a dated snapshot of the state BEFORE this work, kept because the design rationale only makes sense against what it was fixing. - §8 becomes a landed-where table plus per-phase notes, no estimates. Cross-checking every claim against the code turned up three the doc asserted and the code does not do — all now corrected to describe what actually ships: - §4 claimed the VPP editor-floor check also runs "on load". It does not; it runs at install only, so a package installed under a newer editor keeps loading after a downgrade. Recorded as a known gap instead of a feature. - §6 promised a console warning for a runtime with no /api/capabilities. The fallback is deliberately silent, and the section now says why: that is every deployed device, so warning there would fire on every upload. - §6 promised a console warning when a runtime-v4 VPP declares no minRuntimeVersion. There is none; that case is caught at package build time by validate.ts instead. Also corrects 404 → 401 for the legacy-runtime fallback (the / catch-all behind @jwt_required() swallows unknown paths), and turns §10 into decided-vs-open: the runtime's published floor is settled at 4.1.0, and the four findings from review are recorded with the note that three touch shared surface and need a mirrored commit in openplc-web. Docs only — no behaviour change. Co-Authored-By: Claude Opus 5 --- docs/version-compatibility-strategy.md | 227 ++++++++++++++++--------- 1 file changed, 149 insertions(+), 78 deletions(-) diff --git a/docs/version-compatibility-strategy.md b/docs/version-compatibility-strategy.md index f5495657f..29fb91ad1 100644 --- a/docs/version-compatibility-strategy.md +++ b/docs/version-compatibility-strategy.md @@ -1,9 +1,17 @@ # Editor + Runtime + VPP Version Compatibility — Strategy -> Status: **agreed design** (Marcone + Thiago Alves, 2026-08-05) +> Status: **agreed design, implemented** (Marcone + Thiago Alves, 2026-08-05) > Jira: [DOPE-448](https://autonomylogic.atlassian.net/browse/DOPE-448) (epic DOPE-317 — VPP in the Editor) > Scope: `openplc-editor` + `openplc-web` (shared surface — byte-identical), > `openplc-runtime`, `openplc-packages` +> Shipped in: openplc-editor#993 · openplc-runtime#163 · openplc-packages#28 · +> openplc-web#652 — see §8 for what each one covers. + +**Reading this later:** §1 is the design and stays true. **§2 is a snapshot of +the state _before_ this work, dated 2026-08-05** — it describes gaps the PRs +above have since closed, and is kept because the reasoning only makes sense +against what it was fixing. §8 marks what landed. Do not read §2 as a to-do +list. ## 1. The decision @@ -32,7 +40,11 @@ The accepted trade-off: the runtime _advertises_ rather than _enforces_, so a client that skips the check can still upload. This protects the real case (our editor against our runtime) and is not a security boundary — see §7. -## 2. What is missing today, per declaration +## 2. What was missing, per declaration — baseline as of 2026-08-05 + +_Historical. Every gap below is closed by the PRs listed at the top; §8 says +which. Kept verbatim because the design decisions in §1 and §3 only make sense +against the state they were correcting._ ### 2.1 VPP — the field exists, nothing checks it @@ -71,9 +83,11 @@ or scan time. via `ARG RUNTIME_VERSION`), exposed at `GET /api/version` and on the `X-OpenPLC-Runtime-Version` header (`webserver/restapi.py:46`). -There is no endpoint where the runtime states what it needs from an editor. -`handle_upload_file` accepts any ZIP that passes `analyze_zip` (path traversal, -size, ZIP-bomb ratio, denylisted executable extensions). +At the time of writing there was no endpoint where the runtime stated what it +needs from an editor, and `handle_upload_file` accepted any ZIP that passed +`analyze_zip` (path traversal, size, ZIP-bomb ratio, denylisted executable +extensions). `GET /api/capabilities` (§3.1) closes this in openplc-runtime#163; +the upload path itself is deliberately left untouched — see §7. ### 2.3 Editor — the constant exists under a narrower name @@ -148,21 +162,28 @@ Reverse case — editor **4.2.0** against the same runtime: editor 4.2.0 >= runtime's minEditorVersion (4.2.1)? NO → blocked ``` -Nothing is sent. This is the "and vice-versa" direction from the card, and it is -what does not exist today. +Nothing is sent. This is the "and vice-versa" direction from the card — the one +that had no enforcement at all before this work. -**Legacy runtime** — `GET /api/capabilities` returns `404`: +**Legacy runtime** — `GET /api/capabilities` fails. In practice with **401**, +not 404: the runtime's `restapi.py` ends in a +`@restapi_bp.route("/")` catch-all behind `@jwt_required()`, so an +unknown path under `/api/` falls into it and comes back "Missing Authorization +Header". Verified against a real pre-change container. The probe treats any +unreadable answer the same way, so both shapes fall back identically: ``` -GET /api/version → {"version": "v4.1.7"} +GET /api/capabilities → 401 (or 404) +GET /api/version → {"version": "v4.1.7"} runtime declares no floor → nothing to check in that direction runtime 4.1.7 >= MIN_RUNTIME_VERSION (4.1.0)? yes → upload proceeds user-management needs 4.1.9? no → screen hidden ``` -Identical to today's behaviour, plus one console warning that the runtime does -not publish its requirements. +Identical to the previous behaviour. The fallback is deliberately **silent** — +that 401 is the normal answer from every runtime already deployed, so warning on +it would nag on every upload. ### 3.2 VPP → Editor, at install time @@ -208,16 +229,20 @@ connected runtime reports v4.1.7 ## 4. Where each check lives -| Check | Gate | Failure surface | -| ---------------------------------- | ----------------------------------------------------------------- | ---------------------------------- | -| runtime new enough for this editor | `probe-runtime-version.ts` + `MIN_RUNTIME_VERSION` | upload blocked pre-compile | -| editor new enough for this runtime | `probe-runtime-version.ts` + `minEditorVersion` from the endpoint | upload blocked pre-compile | -| editor new enough for this VPP | `package-manager-module.ts::install` (+ on load) | install rejected; package unusable | -| runtime new enough for this VPP | compile pipeline, `runtime-v4` targets | compile blocked | -| per-feature runtime capability | existing predicates in `runtime-version-gate.ts` | UI surface hidden | +| Check | Gate | Failure surface | +| ---------------------------------- | ----------------------------------------------------------------- | ------------------------------------- | +| runtime new enough for this editor | `probe-runtime-version.ts` + `MIN_RUNTIME_VERSION` | upload blocked pre-compile | +| editor new enough for this runtime | `probe-runtime-version.ts` + `minEditorVersion` from the endpoint | upload blocked pre-compile | +| editor new enough for this VPP | `package-manager-module.ts::install` | install rejected, both versions named | +| runtime new enough for this VPP | compile pipeline, `runtime-v4` targets | compile blocked | +| per-feature runtime capability | existing predicates in `runtime-version-gate.ts` | UI surface hidden | Every row is decided in the editor. The runtime and the VPP only declare. +**Not covered: an already-installed package after an editor downgrade.** The +gate runs at install, so a package installed under 4.3 keeps loading on 4.2. +Re-checking on load would close it — see §10 for why it is still open. + ## 5. Error messages A gate that fires is a support ticket unless the message is complete. Following @@ -251,11 +276,19 @@ never "incompatible versions". Everything already in the field predates this work, so absence must be a supported state rather than an error. -| Peer state | Behaviour | -| -------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| Runtime without `/api/capabilities` | no editor floor to check; existing `MIN_RUNTIME_VERSION` gate still applies; console warning | -| Runtime version unparseable (`"v4"`, `"dev"`) | fail closed — current behaviour, unchanged | -| VPP without `minRuntimeVersion` on a `runtime-v4` target | install allowed, runtime match unverifiable, console warning at install | +| Peer state | Behaviour as implemented | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Runtime without `/api/capabilities` | no editor floor to check; `MIN_RUNTIME_VERSION` gate still applies; **silent** — see below | +| Runtime version unparseable (`"v4"`, `"dev"`) | fail closed — previous behaviour, unchanged | +| Runtime declares an _unreadable_ floor (`"4.2"`, junk) | floor ignored, upload proceeds, **no signal** — known gap, see §10 | +| VPP without `minRuntimeVersion` on a `runtime-v4` target | install allowed, runtime match unverifiable, no warning; blocked at package build time instead | + +The first row is silent **on purpose**: a runtime with no `/api/capabilities` is +every device currently deployed, so warning there would fire on every upload from +every editor. The third row is different — a floor that is present but malformed +is a mistake someone made, and swallowing it means a constraint the runtime +believes it is enforcing silently is not. That one deserves a warning and does +not have one yet. Because the runtime never enforces, no closing window is needed on the upload path: an old runtime simply contributes no constraint. The only future tightening @@ -276,49 +309,54 @@ not be described as one. If enforcement is ever needed, the natural follow-up is for the editor to send its version on upload and for the runtime to compare — additive, and not required by anything in this plan. -## 8. Delivery phases - -No ordering constraint between phases: the runtime never blocks, so a phase -shipping early cannot break a peer. Ordered by value delivered. - -**Phase 1 — one semver parser.** Unify `frontend/utils/semver.ts` and -`parseRuntimeVersion` into a single shared helper with one explicit policy for -malformed and pre-release inputs. Existing call sites keep their current -functions as thin wrappers so no behaviour changes and existing tests pass -untouched. Precondition for phases 2–4, which all compare version strings. -_Repos: editor + web (byte-identical) · ~half a day._ - -**Phase 2 — VPP install gate.** `package-manager-module.ts::install` checks -`minEditorVersion` against `APP_VERSION`, next to the signature verification; -same check when loading an already-installed package. `catalog-browser.tsx` -derives its state from the shared helper. Correct -`openplc-packages/docs/package-format.md:69` — it becomes true. -_Repos: editor + web · ~1 day._ - -**Phase 3 — `minRuntimeVersion` in the manifest.** Add the field to -`schema/manifest.schema.json`, conditionally required when any device targets -`runtime-v4`; enforce in `scripts/validate.ts` so a malformed package never -reaches a user; check it in the compile pipeline against the connected runtime. -_Repos: packages + editor + web · ~1 day._ - -**Phase 4 — `GET /api/capabilities`.** New unauthenticated endpoint returning -`runtimeVersion` + `minEditorVersion`, with the constant living beside -`RUNTIME_VERSION` in `webserver/version.py`. -_Repo: runtime · ~half a day._ - -**Phase 5 — editor consumes it.** `probe-runtime-version.ts` reads the endpoint, -treats `404` as "declares nothing", and blocks upload when -`APP_VERSION < minEditorVersion`. Rename `MIN_STRUCPP_RUNTIME_VERSION` to -`MIN_RUNTIME_VERSION` (keeping the old name as an alias) so the global reads as -what it is. -_Repos: editor + web · ~1 day._ - -**Phase 6 — tests.** Table of `(editorVersion, runtimeVersion, vppManifest) → -allow | block | warn` covering each of the four comparisons plus the -declares-nothing and unparseable rows. -_Repos: editor + web · ~half a day._ - -Total: **~4,5 days**. +## 8. Delivery — what landed where + +**All six phases are implemented.** There was deliberately no ordering +constraint between them: the runtime never blocks, so a phase shipping early +cannot break a peer. + +| # | Phase | Repos | Landed in | +| --- | ----------------------------------- | ----------------- | ---------------------------------------- | +| 1 | One semver parser | editor + web | openplc-editor#993 · openplc-web#652 | +| 2 | VPP install gate | editor + web | openplc-editor#993 · openplc-web#652 | +| 3 | `minRuntimeVersion` in the manifest | packages + editor | openplc-packages#28 · openplc-editor#993 | +| 4 | `GET /api/capabilities` | runtime | openplc-runtime#163 | +| 5 | Editor consumes the endpoint | editor + web | openplc-editor#993 · openplc-web#652 | +| 6 | Tests | editor + web | openplc-editor#993 · openplc-web#652 | + +**Phase 1 — one semver parser.** `frontend/utils/semver.ts` owns the parse and +the ordering; `parseRuntimeVersion` and `compareSemver` became thin wrappers, so +no call site changed and existing tests passed untouched. It lives in +`frontend/utils/` rather than `backend/shared/` because the layer rules allow +`backend-shared → utils` and not the reverse. + +**Phase 2 — VPP install gate.** `package-manager-module.ts::install` compares +`minEditorVersion` against `APP_VERSION`, next to the signature verification, so +one gate covers both the catalog and the "Add from file…" path. +`openplc-packages/docs/package-format.md:69` is now true. + +**Phase 3 — `minRuntimeVersion` in the manifest.** Field added to the schema, +conditionally required when any device targets `runtime-v4` and rejected +otherwise, enforced in `scripts/validate.ts`; compared in the compile pipeline +against the connected runtime. + +**Phase 4 — `GET /api/capabilities`.** Unauthenticated endpoint returning +`runtimeVersion` + `minEditorVersion`, constant beside `RUNTIME_VERSION` in +`webserver/version.py`. + +**Phase 5 — editor consumes it.** `probe-runtime-version.ts` prefers the +endpoint and falls back to `/api/version`. Note the correction to the original +plan: a runtime predating the endpoint answers **401**, not 404 — its +`restapi.py` ends in a `@restapi_bp.route("/")` catch-all behind +`@jwt_required()`, so an unknown path lands there. Verified against a real +pre-change container. The probe therefore keys off "can I read a version out of +this answer" rather than off a status code, and covers both shapes. +`MIN_STRUCPP_RUNTIME_VERSION` renamed to `MIN_RUNTIME_VERSION`, old name kept as +an alias. + +**Phase 6 — tests.** Automated coverage of all four comparisons plus the +declares-nothing and unparseable rows. Also verified manually end-to-end with +negative controls, including against a real Raspberry Pi reporting `v4.1.9`. ## 9. Explicitly out of scope @@ -336,15 +374,48 @@ Considered and dropped, so nobody re-derives them: releases that do not exist yet is unknowable — any value either blocks compatible future peers or does nothing. -## 10. Open questions - -1. **What value does the runtime publish as `minEditorVersion` today?** Needs a - concrete audit of when the current bundle layout stabilised. Publishing a - floor that is too high locks out working editors; too low makes the field - decorative. Safest start is the oldest editor known to work with the strucpp - pipeline. -2. **Does an installed-but-incompatible VPP get hidden or shown-as-unusable?** - Hiding is cleaner; showing explains why a board disappeared after an editor - downgrade. -3. **Warning surface for peers that declare nothing** (§6) — console only, or a - one-time notice in the UI? +## 10. Open questions and known gaps + +**Decided** + +1. ~~**What value does the runtime publish as `minEditorVersion`?**~~ → + **`4.1.0`**, set in `webserver/version.py`. That is where the STruC++ + pipeline landed, so it locks out nobody who works today; 4.0.x editors + emitted MatIEC artefacts the runtime cannot build at all. The rule for + raising it is stated on the constant itself: only when an older editor + genuinely produces a bundle this runtime would mis-compile. It is not a build + counter. +2. ~~**Warning surface for a runtime that declares nothing?**~~ → **silent**, for + the reason in §6: that is every deployed device. + +**Still open** + +3. **An unreadable floor is discarded with no signal.** `isVersionAtLeast` + returns `true` when it cannot parse the _minimum_, so + `minEditorVersion: "4.2"` — a plausible hand-written shorthand — disables the + gate entirely and says nothing. The asymmetry is the problem: the same string + is fatal as the _candidate_, with a user-visible message, and invisible as the + _floor_. The log channel is already threaded through the probe; this needs one + warning. No test can catch it today because the symptom is the absence of a + symptom. +4. **A malformed floor in a VPP manifest becomes "no floor".** + `package-manifest-schema.ts` accepts any non-empty string and the install gate + compares leniently, so `minEditorVersion: "garbage"` installs anywhere. + `openplc-packages`' `scripts/validate.ts` covers published packages, but the + install gate exists precisely because a sideloaded `.vpp` never passes through + that validator — for that entry path this schema is the only boundary. Cost of + requiring a strict `x.y.z` is nil: `"4.3"` and `"v5"` are already honoured, so + only total junk changes behaviour. +5. **An installed-but-incompatible VPP keeps loading after an editor downgrade** + (§4). Re-checking `minEditorVersion` on load would close it. Open sub-question + if we do: hide the package, or show it as unusable? Hiding is cleaner; showing + explains why a board disappeared. +6. **Three hand-rolled comparators remain** in `runtime-version-gate.ts` + (`isStrucppCompatibleRuntime`, `isUserManagementCapableRuntime`) doing their + own `if (v.major > 4) …` next to the constants they compare. Both are exactly + equivalent to `isVersionAtLeast(raw, )`, `null` handling included. + Not a bug — the same duplication this work set out to remove, one level up: + the parser got unified, the comparators did not. + +Items 3–6 were raised in review of openplc-editor#993. Items 3, 4 and 6 touch +shared-surface files, so any fix needs the mirrored commit in openplc-web. From 20df50ec4b7e833ddebbfa97acedd953ca577d5b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 6 Aug 2026 16:55:34 -0400 Subject: [PATCH 63/79] fix(compat): one version parser, one comparator, one board lookup (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. Every change here removes a second way of doing something that already had a first way. Version parsing — one parser, not two `parseVersionStrict` / `parseVersionLenient` kept the very split this work set out to remove: the same string meant different things depending on which one a caller reached for. `minEditorVersion: "4.3"` refused a package install and was silently ignored by the runtime gate. One `parseVersion` now applies one rule: a `v` prefix is decoration (`v4.3.2` === `4.3.2`), a missing component is zero (`4.3` === `4.3.0`, `4` === `4.0.0`), and anything else is UNKNOWN (null). Unknown never becomes 0.0.0 behind a caller's back, so a gate can still tell "I cannot read this" from "this is old" and fail closed on the first. `isCompatibleEditorVersion` delegates to `isVersionAtLeast`, so the install gate and the runtime gates cannot disagree; a table test asserts that directly. Consequence worth noting: the legacy `"v4"` header now parses as 4.0.0 instead of being rejected as junk. No gate's answer changes — 4.0.0 is below the 4.1.0 floor either way — but it is refused for the honest reason rather than because the string looked odd. An unreadable floor no longer disappears `PackageManifestSchema` rejects a `minEditorVersion` / `minRuntimeVersion` it cannot parse. `"4.3"`, `"4"`, `"v5"` all pass, so only genuine junk changes behaviour — and a sideloaded .vpp never reaches openplc-packages' validator, which makes this schema the only boundary for that path. When a *runtime* publishes an unreadable floor the probe now logs a warning: the upload still proceeds, but a constraint the runtime believes it is enforcing can no longer go missing in silence. Comparators — a constant, not a hand-rolled comparison `isStrucppCompatibleRuntime` and `isUserManagementCapableRuntime` each open-coded their own `if (v.major > 4) ... return v.minor >= 1` next to the constant they compare against. Both are now `isVersionAtLeast(raw, )`. The old bodies hardcoded the *shape* of the constant: raise MIN_RUNTIME_VERSION to a non-.0 patch and `minor >= 1` keeps answering for the old floor while every behavioural test still passes. Guard tests derive their expectations from the constants so a re-inlined comparison fails immediately. Board lookup — four copies became one `board-info-resolver`, `handleVendorPluginPackaging`, `buildVppArduinoModuleConfig` and `getRuntimeFloorForBoard` each grew their own "first installed package whose devices contain this name" loop. They agreed, which is the only reason this was latent: change the tie-break or start matching on id as well as name and three of the four keep the old behaviour, in a codebase where the symptom is a board compiling against the wrong package's HAL. `findVppDeviceByBoardName` in backend/shared is now the only implementation. It lives there because board-info-resolver is a caller and cannot reach into backend/editor, and it takes the PackageManagerPort the resolver already injects, so editor and web both satisfy it with no new plumbing. First-match-wins and skip-unreadable-manifest were implicit in all four copies and are now stated and tested. Message rendering All three describe* builders route an unreadable version through `formatVersionForDisplay`, so a blank renders as "unknown" instead of leaving a hole in the sentence ("...reports ."). The two DOPE-448 builders had no tests at all; runtime-version-gate.ts went from 66% to 100% function coverage. Also fixes the @deprecated tag on ParsedRuntimeVersion, which pointed at a module that does not exist. Verification: 5958 tests pass, 0 failures. 100% statements/branches/ functions/lines on all five touched shared files. validate:arch, tsc, eslint and prettier clean. compare-surfaces.py reports match=true across 1011 files against the mirrored openplc-web commit. Co-Authored-By: Claude Opus 5 (1M context) --- .../handle-vendor-plugin-packaging.test.ts | 23 ++- .../editor/compiler/compiler-module.ts | 45 +---- .../package-manager/package-manager-module.ts | 32 ++-- .../__tests__/runtime-version-gate.test.ts | 154 +++++++++++++++- .../shared/firmware/runtime-version-gate.ts | 64 ++++--- .../__tests__/find-vpp-device.test.ts | 75 ++++++++ .../shared/hardware/board-info-resolver.ts | 11 +- .../shared/hardware/find-vpp-device.ts | 58 ++++++ .../__tests__/probe-runtime-version.test.ts | 37 ++++ .../shared/library/probe-runtime-version.ts | 21 ++- src/frontend/utils/__tests__/semver.test.ts | 172 +++++++++++++----- src/frontend/utils/semver.ts | 145 ++++++++------- .../__tests__/package-manifest-schema.test.ts | 66 +++++++ .../shared/ports/package-manifest-schema.ts | 26 ++- 14 files changed, 719 insertions(+), 210 deletions(-) create mode 100644 src/backend/shared/hardware/__tests__/find-vpp-device.test.ts create mode 100644 src/backend/shared/hardware/find-vpp-device.ts create mode 100644 src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts diff --git a/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts index 209eb06f7..e44e95fc0 100644 --- a/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts @@ -28,13 +28,28 @@ jest.mock('electron', () => ({ MessageChannelMain: class {}, })) +type FindVppDevice = typeof import('../../../shared/hardware/find-vpp-device') + const listInstalled = jest.fn() const getInstalledPackageManifest = jest.fn() jest.mock('../../package-manager', () => ({ - PackageManagerModule: jest.fn().mockImplementation(() => ({ - listInstalled, - getInstalledPackageManifest, - })), + PackageManagerModule: jest.fn().mockImplementation(() => { + const port = { listInstalled, getInstalledPackageManifest } + return { + ...port, + // Board lookup runs through the shared `findVppDeviceByBoardName`, and + // the mock runs the real one over these two stubs rather than + // re-implementing the search — a stub that resolved boards its own way + // would let the production lookup change without a test noticing. + // `require` (not a top-level import) because jest.mock factories are + // hoisted above the import block. + findDeviceByBoardName: (boardName: string) => + (jest.requireActual('../../../shared/hardware/find-vpp-device') as FindVppDevice).findVppDeviceByBoardName( + port, + boardName, + ), + } + }), })) // eslint-disable-next-line import/first diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 329c5038e..8c2309eb3 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -120,7 +120,6 @@ import JSZip from 'jszip' import type { PlatformOption } from '../../../middleware/shared/ports/types' import { BoardInfoResolver } from '../../shared/hardware/board-info-resolver' -import type { PackageManifest } from '../package-manager' import { PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' import { createDesktopLibraryBuildPort } from './desktop-library-build-port' @@ -2012,28 +2011,16 @@ class CompilerModule { handleOutputData: HandleOutputDataCallback, ): Promise { try { - const packageManager = new PackageManagerModule() - const installed = packageManager.listInstalled() - - let matchingPackagePath: string | null = null - let matchingDevice: PackageManifest['devices'][number] | null = null - - for (const pkg of installed) { - const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) - if (!manifest) continue - const device = manifest.devices.find((d) => d.name === boardTarget) - if (device) { - matchingPackagePath = pkg.path - matchingDevice = device - break - } - } + const match = new PackageManagerModule().findDeviceByBoardName(boardTarget) - if (!matchingDevice || !matchingPackagePath) { + if (!match) { handleOutputData(`Board "${boardTarget}" is not from a VPP package, skipping VPP packaging`, 'info') return } + const matchingPackagePath = match.pkg.path + const matchingDevice = match.device + if (matchingDevice.target.type !== 'runtime-v4') { handleOutputData( `VPP board "${boardTarget}" is not runtime-v4 (target=${matchingDevice.target.type}), skipping VPP packaging`, @@ -2290,26 +2277,12 @@ class CompilerModule { vendorScreenData: Record, ): Promise> { try { - const packageManager = new PackageManagerModule() - const installed = packageManager.listInstalled() - - let matchingPackagePath: string | null = null - let matchingDevice: PackageManifest['devices'][number] | null = null - for (const pkg of installed) { - const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) - if (!manifest) continue - const device = manifest.devices.find((d) => d.name === boardTarget) - if (device) { - matchingPackagePath = pkg.path - matchingDevice = device - break - } - } + const match = new PackageManagerModule().findDeviceByBoardName(boardTarget) - const rawModules = matchingDevice?.moduleSystem?.modules - if (!matchingDevice || !matchingPackagePath || !rawModules || rawModules.length === 0) return [] + const rawModules = match?.device.moduleSystem?.modules + if (!match || !rawModules || rawModules.length === 0) return [] - const pkgPath = matchingPackagePath + const pkgPath = match.pkg.path const modules = await Promise.all( rawModules.map(async (m) => { let configScreenDefinition: unknown diff --git a/src/backend/editor/package-manager/package-manager-module.ts b/src/backend/editor/package-manager/package-manager-module.ts index e588c826f..1b3bad1ba 100644 --- a/src/backend/editor/package-manager/package-manager-module.ts +++ b/src/backend/editor/package-manager/package-manager-module.ts @@ -6,6 +6,8 @@ import { join } from 'path' import { APP_VERSION } from '../../../frontend/data/constants/app-version' import { isCompatibleEditorVersion } from '../../../frontend/utils/semver' import { PackageManifestSchema } from '../../../middleware/shared/ports/package-manifest-schema' +import type { VppDeviceMatch } from '../../shared/hardware/find-vpp-device' +import { findVppDeviceByBoardName } from '../../shared/hardware/find-vpp-device' import { validatePathId } from '../../shared/utils/path-safety' import { TRUSTED_PACKAGE_KEYS } from '../../shared/utils/vpp/trusted-keys' import { verifyPackageSignature } from '../../shared/utils/vpp/verify-package-signature' @@ -302,16 +304,26 @@ class PackageManagerModule { return pkg?.path ?? null } + /** + * The installed VPP device named `boardName`, with its package and + * manifest — or null when no installed package provides it. + * + * `boardTarget` travels through the compile pipeline as a device + * *name*, so every consumer that needs the package behind a board + * starts here. Delegates to the shared `findVppDeviceByBoardName` so + * this and `board-info-resolver` (which cannot import this module) + * resolve a board the same way. + */ + findDeviceByBoardName(boardName: string): VppDeviceMatch | null { + return findVppDeviceByBoardName(this, boardName) + } + /** * `package.minRuntimeVersion` of the installed package that provides * `boardName`, or null when no installed package does, when the * matching device is not a `runtime-v4` target, or when the package * declares no floor (DOPE-448). * - * Board lookup is by device *name* because that is the identifier the - * compile pipeline carries as `boardTarget` — the same match - * `handleVendorPluginPackaging` performs. - * * Only runtime-v4 devices can carry a meaningful floor: their HAL is * plugin code built against the runtime's API. An `arduino-cli` * device never talks to the runtime, so a floor there would be a @@ -319,15 +331,9 @@ class PackageManagerModule { * it at authoring time, and this returns null if one slips through. */ getRuntimeFloorForBoard(boardName: string): string | null { - for (const pkg of this.listInstalled()) { - const manifest = this.getInstalledPackageManifest(pkg.packageId) - if (!manifest) continue - const device = manifest.devices.find((d) => d.name === boardName) - if (!device) continue - if (device.target.type !== 'runtime-v4') return null - return manifest.package.minRuntimeVersion ?? null - } - return null + const match = this.findDeviceByBoardName(boardName) + if (!match || match.device.target.type !== 'runtime-v4') return null + return match.manifest.package.minRuntimeVersion ?? null } private readRegistry(): PackageRegistry { diff --git a/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts b/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts index 3f8733187..f0148cc15 100644 --- a/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts +++ b/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts @@ -1,7 +1,11 @@ +import { isVersionAtLeast } from '../../../../frontend/utils/semver' import { + describeEditorTooOldForRuntime, describeIncompatibleRuntime, + describeVppRuntimeMismatch, isStrucppCompatibleRuntime, isUserManagementCapableRuntime, + MIN_RUNTIME_VERSION, MIN_STRUCPP_RUNTIME_VERSION, MIN_USER_MANAGEMENT_RUNTIME_VERSION, parseRuntimeVersion, @@ -59,13 +63,22 @@ describe('parseRuntimeVersion', () => { }) }) - it('rejects the legacy hardcoded "v4" string', () => { - expect(parseRuntimeVersion('v4')).toBeNull() + // A missing component is zero everywhere in this codebase, so the legacy + // header parses rather than being rejected as junk. The gate's answer is + // unchanged — see `isStrucppCompatibleRuntime` below — because 4.0.0 is + // genuinely below the 4.1.0 floor. The distinction matters: "I cannot read + // this" and "this is old" are different facts and only one of them is true. + it('reads the legacy hardcoded "v4" string as 4.0.0', () => { + expect(parseRuntimeVersion('v4')).toEqual({ major: 4, minor: 0, patch: 0, prerelease: undefined }) + }) + + it('fills a missing patch component with zero', () => { + expect(parseRuntimeVersion('v4.1')).toEqual({ major: 4, minor: 1, patch: 0, prerelease: undefined }) + expect(parseRuntimeVersion('v4.1')).toEqual(parseRuntimeVersion('4.1.0')) }) it('rejects ambiguous / non-numeric strings', () => { expect(parseRuntimeVersion('dev')).toBeNull() - expect(parseRuntimeVersion('v4.1')).toBeNull() expect(parseRuntimeVersion('v4.x.0')).toBeNull() expect(parseRuntimeVersion(' ')).toBeNull() }) @@ -102,10 +115,72 @@ describe('isStrucppCompatibleRuntime', () => { }) it('rejects the legacy "v4" header + any other unparseable string', () => { + // `v4` now parses (as 4.0.0) and is refused on its merits; the rest are + // unreadable and are refused because an unknown runtime never clears a + // floor. Both paths must stay closed. expect(isStrucppCompatibleRuntime('v4')).toBe(false) expect(isStrucppCompatibleRuntime('dev')).toBe(false) expect(isStrucppCompatibleRuntime(null)).toBe(false) expect(isStrucppCompatibleRuntime(undefined)).toBe(false) + expect(isStrucppCompatibleRuntime('')).toBe(false) + }) + + it('accepts a two-part version at or above the floor', () => { + expect(isStrucppCompatibleRuntime('v4.1')).toBe(true) + expect(isStrucppCompatibleRuntime('4.2')).toBe(true) + expect(isStrucppCompatibleRuntime('4.0')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Every capability gate is "a constant + isVersionAtLeast", nothing more +// --------------------------------------------------------------------------- +// +// These read as tautologies today, and that is the point: they fail the moment +// someone re-inlines a comparison next to a constant. The version this replaced +// answered `v.minor >= 1` for the strucpp gate — correct only because the floor +// happened to end in `.0`. Raise the floor to `4.1.5` and that body keeps +// admitting 4.1.0 while every behavioural test still passes, because the tests +// were written against the old floor too. Deriving the expectation from the +// constant is the only assertion that survives a bump. +describe('capability gates track their constants', () => { + const CANDIDATES = [ + null, + undefined, + '', + 'dev', + 'garbage', + 'v4', + '4.0', + '4.1', + '3.9.9', + '4.0.9', + '4.1.0', + '4.1.0-rc.3', + '4.1.8', + '4.1.9', + '4.1.9-rc.1', + '4.1.10', + '4.2.0', + '4.10.0', + '5.0.0', + ] + + it.each(CANDIDATES)('isStrucppCompatibleRuntime(%p) === isVersionAtLeast(%p, MIN_RUNTIME_VERSION)', (raw) => { + expect(isStrucppCompatibleRuntime(raw)).toBe(isVersionAtLeast(raw, MIN_RUNTIME_VERSION)) + }) + + it.each(CANDIDATES)('isUserManagementCapableRuntime(%p) tracks its own constant', (raw) => { + expect(isUserManagementCapableRuntime(raw)).toBe(isVersionAtLeast(raw, MIN_USER_MANAGEMENT_RUNTIME_VERSION)) + }) + + it('puts each gate exactly on its own floor', () => { + expect(isStrucppCompatibleRuntime(MIN_RUNTIME_VERSION)).toBe(true) + expect(isUserManagementCapableRuntime(MIN_USER_MANAGEMENT_RUNTIME_VERSION)).toBe(true) + // The floors are ordered, so the lower gate must be open where the higher + // one is shut — a single `minor >= 1` body cannot express that difference. + expect(isStrucppCompatibleRuntime('4.1.0')).toBe(true) + expect(isUserManagementCapableRuntime('4.1.0')).toBe(false) }) }) @@ -123,3 +198,76 @@ describe('describeIncompatibleRuntime', () => { expect(describeIncompatibleRuntime(' ')).toContain('unknown') }) }) + +describe('describeEditorTooOldForRuntime', () => { + const args = { runtimeVersion: 'v4.3.0', minEditorVersion: '4.3.0', editorVersion: '4.2.10' } + + it('names both versions and the action that fixes it', () => { + const msg = describeEditorTooOldForRuntime(args) + expect(msg).toContain('v4.3.0') + expect(msg).toContain('4.3.0') + expect(msg).toContain('4.2.10') + expect(msg).toContain('Update the editor') + }) + + it('includes the device address when the platform knows one', () => { + expect(describeEditorTooOldForRuntime({ ...args, deviceLabel: '10.0.0.1' })).toContain('on 10.0.0.1') + }) + + it('omits the device clause when no label is available', () => { + // Web reaches the device through an orchestrator agent, so there is no + // address the user would recognise — better to say nothing than to print + // an agent id nobody can act on. + expect(describeEditorTooOldForRuntime(args)).not.toContain(' on ') + }) + + // The message must never contain a hole where a version should be. A blank + // runtime version is not "no version" — it is a version we failed to + // establish, and the user needs to be told which of the two we could not read. + const UNREADABLE: Array<[string | null | undefined, string]> = [ + ['', 'an empty string'], + [' ', 'whitespace only'], + [null, 'null'], + [undefined, 'undefined'], + ] + + it.each(UNREADABLE)('renders a %p runtime version as "unknown" (%s)', (runtimeVersion) => { + const msg = describeEditorTooOldForRuntime({ ...args, runtimeVersion }) + expect(msg).toContain('Runtime unknown requires') + }) +}) + +describe('describeVppRuntimeMismatch', () => { + const args = { boardTarget: 'SLM-RP4', minRuntimeVersion: '4.2.0', runtimeVersion: 'v4.1.7' } + + it('names the board, the floor, and the reported runtime', () => { + const msg = describeVppRuntimeMismatch(args) + expect(msg).toContain('"SLM-RP4"') + expect(msg).toContain('v4.2.0') + expect(msg).toContain('v4.1.7') + expect(msg).toContain('Upgrade the runtime') + }) + + it('names the device when the platform knows its address', () => { + expect(describeVppRuntimeMismatch({ ...args, deviceLabel: '10.0.0.1' })).toContain( + 'The runtime at 10.0.0.1 reports', + ) + }) + + it('falls back to "the connected runtime" without a label', () => { + expect(describeVppRuntimeMismatch(args)).toContain('The connected runtime reports') + }) + + const UNREADABLE: Array<[string | null | undefined, string]> = [ + ['', 'an empty string'], + [' ', 'whitespace only'], + [null, 'null'], + [undefined, 'undefined'], + ] + + it.each(UNREADABLE)('renders a %p runtime version as "unknown" (%s)', (runtimeVersion) => { + const msg = describeVppRuntimeMismatch({ ...args, runtimeVersion }) + expect(msg).toContain('reports unknown.') + expect(msg).not.toContain('reports .') + }) +}) diff --git a/src/backend/shared/firmware/runtime-version-gate.ts b/src/backend/shared/firmware/runtime-version-gate.ts index 5efedb694..8313493ad 100644 --- a/src/backend/shared/firmware/runtime-version-gate.ts +++ b/src/backend/shared/firmware/runtime-version-gate.ts @@ -14,8 +14,17 @@ * * The string is the GitHub release tag baked in at image build time * (see openplc-runtime/.github/workflows/docker.yml). Older - * runtimes that pre-date this work return a hardcoded "v4" string; - * that's intentionally unparseable here so the gate blocks them. + * runtimes that pre-date this work return a hardcoded "v4" string, + * which reads as 4.0.0 and is blocked on its merits — 4.0.0 is the + * MatIEC line. + * + * Every gate below is a MINIMUM VERSION and nothing else, so each one + * is a constant plus a call to `isVersionAtLeast`. No gate open-codes + * its own comparison: a hand-rolled `v.minor >= 1` hardcodes the shape + * of the constant beside it, and the two then drift the moment someone + * raises the constant to a version whose patch is not zero — the gate + * keeps answering for the old floor and every test still passes. + * Adding a capability gate means adding a constant, not a comparator. * * Shared by both openplc-editor (which uploads to remote runtimes) * and openplc-web (which gates push-to-device through the @@ -27,7 +36,7 @@ // `backend-shared -> utils` is allowed, and using `@root/` here would have // skipped the check rather than passed it. import type { ParsedVersion } from '../../../frontend/utils/semver' -import { parseVersionStrict } from '../../../frontend/utils/semver' +import { formatVersionForDisplay, isVersionAtLeast, parseVersion } from '../../../frontend/utils/semver' /** * Oldest runtime this editor will upload to — the editor's own @@ -44,39 +53,42 @@ export const MIN_RUNTIME_VERSION = '4.1.0' /** @deprecated Use `MIN_RUNTIME_VERSION`. */ export const MIN_STRUCPP_RUNTIME_VERSION = MIN_RUNTIME_VERSION -/** @deprecated Use `ParsedVersion` from `shared/utils/version-compare`. */ +/** @deprecated Use `ParsedVersion` from `frontend/utils/semver`. */ export type ParsedRuntimeVersion = ParsedVersion /** - * Parses a runtime version string. Returns null when the string - * doesn't carry enough information to compare — e.g. the legacy `"v4"` - * or `"dev"` builds. Callers treat null as "incompatible". + * Parses a runtime version string. Returns null when the string is not + * a version at all — `"dev"`, `"garbage"`, `""`. Callers treat null as + * "incompatible", so a runtime that cannot say what it is never clears + * a floor. + * + * Note what is NOT null: the legacy `"v4"` header parses as `4.0.0` and + * `"4.1"` as `4.1.0`, because a missing component is zero everywhere in + * this codebase. Neither changes any gate's answer — `4.0.0` is still + * below `MIN_RUNTIME_VERSION`, so the legacy header is still refused, + * now for the honest reason that 4.0.0 predates STruC++ rather than + * because the string looked odd. * - * Delegates to the shared strict parser so the VPP surface and the - * runtime gates can never drift apart on what `"v4"` or `"4.1"` means. + * Delegates to the shared parser so the VPP surface and the runtime + * gates can never drift apart on what a given string means. */ export function parseRuntimeVersion(raw: string | null | undefined): ParsedRuntimeVersion | null { - return parseVersionStrict(raw) + return parseVersion(raw) } /** * Returns true iff the runtime version string represents a runtime - * that speaks the STruC++ wire format (i.e. ≥ 4.1.0, including - * pre-release tags like `v4.1.0-rc.3`). + * that speaks the STruC++ wire format (i.e. ≥ `MIN_RUNTIME_VERSION`, + * including pre-release tags like `v4.1.0-rc.3`). * * Note: by strict semver, `4.1.0-rc.3 < 4.1.0`. We deliberately * deviate here because the rc tags on the v4.1.0 line ARE the * builds shipping STruC++ — there is no "older 4.1.0" the rc lineage - * would be a pre-release of. + * would be a pre-release of. `isVersionAtLeast` ignores pre-release + * in ordering for exactly this reason. */ export function isStrucppCompatibleRuntime(raw: string | null | undefined): boolean { - const v = parseRuntimeVersion(raw) - if (!v) return false - if (v.major > 4) return true - if (v.major < 4) return false - // major === 4: minor must be ≥ 1 (i.e. v4.1.x is the strucpp line). - // patch + prerelease don't matter past that. - return v.minor >= 1 + return isVersionAtLeast(raw, MIN_RUNTIME_VERSION) } /** Minimum runtime version that ships the user-management API @@ -92,11 +104,7 @@ export const MIN_USER_MANAGEMENT_RUNTIME_VERSION = '4.1.9' * gate's treatment of the rc lineage. */ export function isUserManagementCapableRuntime(raw: string | null | undefined): boolean { - const v = parseRuntimeVersion(raw) - if (!v) return false - if (v.major !== 4) return v.major > 4 - if (v.minor !== 1) return v.minor > 1 - return v.patch >= 9 + return isVersionAtLeast(raw, MIN_USER_MANAGEMENT_RUNTIME_VERSION) } /** @@ -105,7 +113,7 @@ export function isUserManagementCapableRuntime(raw: string | null | undefined): * "unknown") is included so the user can match it to the device. */ export function describeIncompatibleRuntime(raw: string | null | undefined): string { - const reported = raw && raw.trim().length > 0 ? raw.trim() : 'unknown' + const reported = formatVersionForDisplay(raw) return ( `Runtime version ${reported} is not compatible with this editor. ` + `Upload requires OpenPLC Runtime v${MIN_RUNTIME_VERSION} or newer (STruC++ pipeline). ` + @@ -126,7 +134,7 @@ export function describeEditorTooOldForRuntime(args: { editorVersion: string deviceLabel?: string }): string { - const runtime = args.runtimeVersion?.trim() ?? 'unknown' + const runtime = formatVersionForDisplay(args.runtimeVersion) const where = args.deviceLabel ? ` on ${args.deviceLabel}` : '' return ( `Runtime ${runtime}${where} requires OpenPLC Editor ${args.minEditorVersion} or newer. ` + @@ -148,7 +156,7 @@ export function describeVppRuntimeMismatch(args: { runtimeVersion: string | null | undefined deviceLabel?: string }): string { - const runtime = args.runtimeVersion?.trim() ?? 'unknown' + const runtime = formatVersionForDisplay(args.runtimeVersion) const where = args.deviceLabel ? `The runtime at ${args.deviceLabel} reports` : 'The connected runtime reports' return ( `Board "${args.boardTarget}" requires OpenPLC Runtime v${args.minRuntimeVersion} or newer. ` + diff --git a/src/backend/shared/hardware/__tests__/find-vpp-device.test.ts b/src/backend/shared/hardware/__tests__/find-vpp-device.test.ts new file mode 100644 index 000000000..b82307924 --- /dev/null +++ b/src/backend/shared/hardware/__tests__/find-vpp-device.test.ts @@ -0,0 +1,75 @@ +import type { InstalledPackage, PackageManifest } from '../../../../middleware/shared/ports/types' +import type { PackageManagerPort } from '../board-info-resolver' +import { findVppDeviceByBoardName } from '../find-vpp-device' + +const pkg = (packageId: string): InstalledPackage => + ({ packageId, path: `/packages/${packageId}` }) as unknown as InstalledPackage + +const manifest = (packageId: string, deviceNames: string[]): PackageManifest => + ({ + package: { id: packageId, name: packageId, version: '1.0.0' }, + devices: deviceNames.map((name) => ({ id: name.toLowerCase(), name, target: { type: 'runtime-v4' } })), + }) as unknown as PackageManifest + +/** A package source backed by plain in-memory maps. */ +const source = ( + installed: InstalledPackage[], + manifests: Record, +): PackageManagerPort => ({ + listInstalled: () => installed, + getInstalledPackageManifest: (packageId) => manifests[packageId] ?? null, +}) + +describe('findVppDeviceByBoardName', () => { + it('returns the package, manifest and device for a board a VPP provides', () => { + const port = source([pkg('vendor.a')], { 'vendor.a': manifest('vendor.a', ['SLM-RP4', 'P2-722']) }) + const match = findVppDeviceByBoardName(port, 'P2-722') + expect(match?.pkg.packageId).toBe('vendor.a') + expect(match?.manifest.package.id).toBe('vendor.a') + expect(match?.device.name).toBe('P2-722') + }) + + it('searches every installed package, not just the first', () => { + const port = source([pkg('vendor.a'), pkg('vendor.b')], { + 'vendor.a': manifest('vendor.a', ['SLM-RP4']), + 'vendor.b': manifest('vendor.b', ['P2-722']), + }) + expect(findVppDeviceByBoardName(port, 'P2-722')?.pkg.packageId).toBe('vendor.b') + }) + + it('returns null for a board no installed package provides', () => { + const port = source([pkg('vendor.a')], { 'vendor.a': manifest('vendor.a', ['SLM-RP4']) }) + // The ordinary case: a built-in hals.json board with no VPP behind it. + expect(findVppDeviceByBoardName(port, 'Arduino Uno')).toBeNull() + }) + + it('returns null when nothing is installed', () => { + expect(findVppDeviceByBoardName(source([], {}), 'SLM-RP4')).toBeNull() + }) + + // The behaviour every copy of this loop shared and none of them stated. + // Pinned here so a future change to it is a deliberate, single edit rather + // than three sites drifting apart. + it('takes the first match in listInstalled order when two packages collide', () => { + const port = source([pkg('vendor.a'), pkg('vendor.b')], { + 'vendor.a': manifest('vendor.a', ['SLM-RP4']), + 'vendor.b': manifest('vendor.b', ['SLM-RP4']), + }) + expect(findVppDeviceByBoardName(port, 'SLM-RP4')?.pkg.packageId).toBe('vendor.a') + }) + + it('skips a package whose manifest cannot be read and keeps searching', () => { + // A single corrupt install must not hide a board another package provides. + const port = source([pkg('vendor.broken'), pkg('vendor.b')], { + 'vendor.broken': null, + 'vendor.b': manifest('vendor.b', ['SLM-RP4']), + }) + expect(findVppDeviceByBoardName(port, 'SLM-RP4')?.pkg.packageId).toBe('vendor.b') + }) + + it('matches on device name exactly', () => { + const port = source([pkg('vendor.a')], { 'vendor.a': manifest('vendor.a', ['SLM-RP4']) }) + expect(findVppDeviceByBoardName(port, 'slm-rp4')).toBeNull() + expect(findVppDeviceByBoardName(port, 'SLM-RP4 ')).toBeNull() + }) +}) diff --git a/src/backend/shared/hardware/board-info-resolver.ts b/src/backend/shared/hardware/board-info-resolver.ts index c241b2f9d..148fcf61f 100644 --- a/src/backend/shared/hardware/board-info-resolver.ts +++ b/src/backend/shared/hardware/board-info-resolver.ts @@ -26,6 +26,7 @@ import type { DebugSpec } from '../../../middleware/shared/ports/debug-spec-types' import type { InstalledPackage, PackageManifest, PlatformOption } from '../../../middleware/shared/ports/types' import type { TargetCapabilities } from '../../../middleware/shared/utils/target-capabilities/types' +import { findVppDeviceByBoardName } from './find-vpp-device' // --------------------------------------------------------------------------- // Public shapes @@ -257,14 +258,8 @@ export class BoardInfoResolver { #tryVppLookup( boardName: string, ): Omit | null { - for (const pkg of this.config.packageManager.listInstalled()) { - const manifest = this.config.packageManager.getInstalledPackageManifest(pkg.packageId) - if (!manifest) continue - const device = manifest.devices.find((d) => d.name === boardName) - if (!device) continue - return this.#fromVppDevice(device, pkg, manifest) - } - return null + const match = findVppDeviceByBoardName(this.config.packageManager, boardName) + return match ? this.#fromVppDevice(match.device, match.pkg, match.manifest) : null } #fromVppDevice( diff --git a/src/backend/shared/hardware/find-vpp-device.ts b/src/backend/shared/hardware/find-vpp-device.ts new file mode 100644 index 000000000..28cfe07a1 --- /dev/null +++ b/src/backend/shared/hardware/find-vpp-device.ts @@ -0,0 +1,58 @@ +/** + * The one way to answer "which installed VPP provides this board?". + * + * `boardTarget` travels through the compile pipeline as a plain device + * *name* — the string the user picked in the board dropdown. Four call + * sites needed to turn it back into the package and manifest entry it + * came from (board build info, VPP plugin packaging, module config + * screens, the runtime-version floor), and each had grown its own copy + * of the same loop. + * + * They agreed, which is the only reason this was a latent bug and not + * an open one: four copies of "first package whose `devices` contains a + * matching name wins" that nothing forced to stay in agreement. Change + * the tie-break, add a namespacing rule, start matching on `id` as well + * as `name` — and three of the four would keep the old behaviour, in a + * codebase where the symptom is a board that compiles against the wrong + * package's HAL. + * + * Lives in `backend/shared` because `board-info-resolver` (also shared) + * is one of the callers and cannot reach into `backend/editor`. It takes + * the same narrow `PackageManagerPort` the resolver already injects, so + * editor and web both satisfy it without new plumbing. + */ + +import type { InstalledPackage, PackageManifest } from '../../../middleware/shared/ports/types' +import type { PackageManagerPort } from './board-info-resolver' + +/** An installed VPP device, with the package and manifest it came from. */ +export interface VppDeviceMatch { + /** Registry entry — carries `packageId` and the on-disk `path`. */ + pkg: InstalledPackage + /** The full manifest, for package-level fields (`minRuntimeVersion`, …). */ + manifest: PackageManifest + /** The matched `devices[]` entry. */ + device: PackageManifest['devices'][number] +} + +/** + * Find the installed VPP device whose name is `boardName`, or null when + * no installed package provides it (the ordinary case for a built-in + * hals.json board). + * + * **First match wins**, in `listInstalled()` order. Two packages + * shipping a device of the same name is an authoring collision, not a + * situation with a right answer; resolving it consistently everywhere + * matters more than which one is picked. Packages whose manifest cannot + * be read are skipped rather than treated as empty, so a single corrupt + * install cannot hide a board another package provides. + */ +export function findVppDeviceByBoardName(packageManager: PackageManagerPort, boardName: string): VppDeviceMatch | null { + for (const pkg of packageManager.listInstalled()) { + const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) + if (!manifest) continue + const device = manifest.devices.find((d) => d.name === boardName) + if (device) return { pkg, manifest, device } + } + return null +} diff --git a/src/backend/shared/library/__tests__/probe-runtime-version.test.ts b/src/backend/shared/library/__tests__/probe-runtime-version.test.ts index 27030b35a..c5d8b9c88 100644 --- a/src/backend/shared/library/__tests__/probe-runtime-version.test.ts +++ b/src/backend/shared/library/__tests__/probe-runtime-version.test.ts @@ -133,6 +133,43 @@ describe('probeRuntimeVersion — capabilities endpoint', () => { expect(log).not.toHaveBeenCalled() }) + // The shorthands a runtime is likely to publish by hand all parse, and are + // enforced as their zero-filled equivalent — no warning, because nothing is + // being dropped. + it.each([['4.2'], ['4'], ['v5'], ['4.2.1-rc.1']])( + 'passes a %p floor through without complaint', + async (minEditorVersion) => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: true, body: { runtimeVersion: 'v4.2.0', minEditorVersion } }), + fetchVersion: versionMustNotBeCalled(), + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion }) + expect(log).not.toHaveBeenCalled() + }, + ) + + // A floor that is present but unreadable declares nothing, which is the safe + // answer for the upload and the wrong one for whoever wrote it: the runtime + // believes it is enforcing a constraint that is not being applied. The + // upload still proceeds — refusing to talk to a device over a typo in its + // metadata would be worse — but it can no longer happen in silence. + it('warns when the runtime declares a floor nobody can read, and still returns it', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ + success: true, + body: { runtimeVersion: 'v4.2.0', minEditorVersion: 'garbage' }, + }), + fetchVersion: versionMustNotBeCalled(), + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion: 'garbage' }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('unreadable minEditorVersion ("garbage")'), 'warning') + expect(log).toHaveBeenCalledWith(expect.stringContaining('not being enforced'), 'warning') + }) + it('reports minEditorVersion=null when the endpoint answers without that field', async () => { const log = jest.fn() const result = await probeRuntimeVersion({ diff --git a/src/backend/shared/library/probe-runtime-version.ts b/src/backend/shared/library/probe-runtime-version.ts index 1190265c0..5a2f46209 100644 --- a/src/backend/shared/library/probe-runtime-version.ts +++ b/src/backend/shared/library/probe-runtime-version.ts @@ -27,6 +27,11 @@ * Pure: no I/O. Caller supplies the transport via `fetchVersion`. */ +// Relative import on purpose: `npm run validate:arch` only inspects relative +// specifiers, so this path is actually checked against the layer rules — +// `backend-shared -> utils` is allowed. +import { isValidVersion } from '../../../frontend/utils/semver' + /** Outcome of the transport-level fetch. Adapters return this from * their HTTPS / orchestrator round-trip; the shared helper takes * it from here. */ @@ -146,7 +151,21 @@ async function tryFetchCapabilities(opts: ProbeRuntimeVersionOptions): Promise

{ +describe('parseVersion', () => { it('parses a plain three-part version', () => { - expect(parseVersionStrict('4.1.9')).toEqual({ major: 4, minor: 1, patch: 9, prerelease: undefined }) + expect(parseVersion('4.1.9')).toEqual({ major: 4, minor: 1, patch: 9, prerelease: undefined }) }) - it('accepts the tag-style v prefix the runtime reports', () => { - expect(parseVersionStrict('v4.2.0')).toEqual({ major: 4, minor: 2, patch: 0, prerelease: undefined }) + // A `v` prefix is decoration, not meaning: the runtime reports `v4.2.0`, git + // tags carry `v`, and hand-written manifests use both. They must compare the + // same or the same release is two different versions depending on who typed it. + it('treats a v prefix as identical to no prefix', () => { + expect(parseVersion('v4.3.2')).toEqual(parseVersion('4.3.2')) + expect(compareSemver('v4.3.2', '4.3.2')).toBe(0) + expect(isVersionAtLeast('v4.3.2', '4.3.2')).toBe(true) + expect(isVersionAtLeast('4.3.2', 'v4.3.2')).toBe(true) + }) + + // A floor written as `"4.3"` means 4.3.0 and is enforced as 4.3.0. Anything + // else and the same shorthand is honoured by one gate and ignored by another. + it('fills missing components with zero', () => { + expect(parseVersion('4.3')).toEqual(parseVersion('4.3.0')) + expect(parseVersion('4')).toEqual(parseVersion('4.0.0')) + expect(parseVersion('v4')).toEqual(parseVersion('4.0.0')) + expect(parseVersion('4.3')).toEqual({ major: 4, minor: 3, patch: 0, prerelease: undefined }) + expect(parseVersion('4')).toEqual({ major: 4, minor: 0, patch: 0, prerelease: undefined }) }) it('captures pre-release and build suffixes without failing', () => { - expect(parseVersionStrict('4.1.0-rc.3')?.prerelease).toBe('rc.3') - expect(parseVersionStrict('4.1.0+build.5')?.prerelease).toBe('build.5') + expect(parseVersion('4.1.0-rc.3')?.prerelease).toBe('rc.3') + expect(parseVersion('4.1.0+build.5')?.prerelease).toBe('build.5') + expect(parseVersion('4.1-rc.3')).toEqual({ major: 4, minor: 1, patch: 0, prerelease: 'rc.3' }) }) it('tolerates surrounding whitespace', () => { - expect(parseVersionStrict(' 4.1.9 ')).toEqual({ major: 4, minor: 1, patch: 9, prerelease: undefined }) + expect(parseVersion(' 4.1.9 ')).toEqual({ major: 4, minor: 1, patch: 9, prerelease: undefined }) }) - // The whole point of the strict parser: these are the values a runtime in - // the field actually reports when it cannot identify itself, and every one - // of them must stay unparseable so a gate fails closed instead of guessing. + // Unknown is not a version. It must not silently become 0.0.0 inside the + // parser, because a caller that cannot tell "unknown" from "0.0.0" cannot + // fail closed on the first and pass on the second. it.each([ - ['v4', 'the legacy hardcoded header'], - ['4.1', 'a two-part version'], ['dev', 'a source build with no CI tag'], ['garbage', 'anything else'], ['', 'an empty string'], + [' ', 'whitespace only'], + ['4.1 beta', 'trailing garbage after a valid prefix'], + ['abc.def.ghi', 'non-numeric components'], + ['4,3,0', 'the wrong separator'], + ['.1.2', 'a missing major'], ])('returns null for %p (%s)', (input) => { - expect(parseVersionStrict(input)).toBeNull() + expect(parseVersion(input)).toBeNull() }) it('returns null for null and undefined', () => { - expect(parseVersionStrict(null)).toBeNull() - expect(parseVersionStrict(undefined)).toBeNull() + expect(parseVersion(null)).toBeNull() + expect(parseVersion(undefined)).toBeNull() }) }) -describe('parseVersionLenient', () => { - it('parses a plain three-part version', () => { - expect(parseVersionLenient('4.1.9')).toEqual({ major: 4, minor: 1, patch: 9 }) +describe('isValidVersion', () => { + it('accepts every shorthand the parser accepts', () => { + for (const raw of ['4.3.2', 'v4.3.2', '4.3', '4', 'v5', '4.1.0-rc.1', '4.1.0+build.5']) { + expect(isValidVersion(raw)).toBe(true) + } }) - it('fills missing components with zero', () => { - expect(parseVersionLenient('4.1')).toEqual({ major: 4, minor: 1, patch: 0 }) - expect(parseVersionLenient('4')).toEqual({ major: 4, minor: 0, patch: 0 }) + it('rejects what the parser cannot read', () => { + for (const raw of ['garbage', 'next', '', ' ', '4,3,0', null, undefined]) { + expect(isValidVersion(raw)).toBe(false) + } }) +}) - it('strips the v prefix and any suffix before parsing', () => { - expect(parseVersionLenient('v4.1.9')).toEqual({ major: 4, minor: 1, patch: 9 }) - expect(parseVersionLenient('4.1.9-rc.1')).toEqual({ major: 4, minor: 1, patch: 9 }) - expect(parseVersionLenient('4.1.9+build.5')).toEqual({ major: 4, minor: 1, patch: 9 }) +describe('formatVersionForDisplay', () => { + it('trims a readable version', () => { + expect(formatVersionForDisplay(' v4.2.0 ')).toBe('v4.2.0') }) - // Degrading to the lowest possible version means a corrupt manifest field - // loses every comparison rather than winning one. - it.each([ - ['garbage', 'a non-numeric string'], - ['abc.def.ghi', 'non-numeric components'], + // Every "incompatible versions" message must render an unestablished version + // the same way. A blank leaves a hole in the sentence and tells the user + // nothing about which of the two versions could not be read. + const UNREADABLE: Array<[string | null | undefined, string]> = [ ['', 'an empty string'], - ])('degrades %p to 0.0.0 (%s)', (input) => { - expect(parseVersionLenient(input)).toEqual({ major: 0, minor: 0, patch: 0 }) - }) + [' ', 'whitespace only'], + [null, 'null'], + [undefined, 'undefined'], + ] - it('degrades null and undefined to 0.0.0', () => { - expect(parseVersionLenient(null)).toEqual({ major: 0, minor: 0, patch: 0 }) - expect(parseVersionLenient(undefined)).toEqual({ major: 0, minor: 0, patch: 0 }) + it.each(UNREADABLE)('renders %p as "unknown" (%s)', (raw) => { + expect(formatVersionForDisplay(raw)).toBe('unknown') }) }) @@ -113,6 +136,11 @@ describe('isVersionAtLeast', () => { expect(isVersionAtLeast('4.2.10', '4.2.1')).toBe(true) }) + it('compares numerically, not lexicographically', () => { + expect(isVersionAtLeast('4.10.0', '4.9.0')).toBe(true) + expect(isVersionAtLeast('4.9.0', '4.10.0')).toBe(false) + }) + it('passes when the candidate sits exactly on the floor', () => { expect(isVersionAtLeast('4.2.1', '4.2.1')).toBe(true) }) @@ -125,6 +153,18 @@ describe('isVersionAtLeast', () => { expect(isVersionAtLeast('v4.1.9-rc.1', '4.1.9')).toBe(true) }) + // The shorthand case. `"4.3"` as a floor must block a 4.2.10 editor exactly + // as `"4.3.0"` would — this is the asymmetry that let a runtime publish a + // floor nobody enforced. + it('enforces a partial floor exactly as its zero-filled equivalent', () => { + expect(isVersionAtLeast('4.2.10', '4.3')).toBe(false) + expect(isVersionAtLeast('4.2.10', '4.3.0')).toBe(false) + expect(isVersionAtLeast('4.3.0', '4.3')).toBe(true) + expect(isVersionAtLeast('4.2.10', '5')).toBe(false) + expect(isVersionAtLeast('5.0.0', '5')).toBe(true) + expect(isVersionAtLeast('4.2.10', 'v4.3')).toBe(false) + }) + // A peer that asks for nothing gets nothing enforced — this is what keeps // runtimes predating /api/capabilities working unchanged. const NOTHING_DECLARED: Array<[string | null | undefined, string]> = [ @@ -137,16 +177,20 @@ describe('isVersionAtLeast', () => { expect(isVersionAtLeast('4.2.0', floor)).toBe(true) }) - it('passes when the floor itself is unparseable, since it declares nothing', () => { + // An unreadable floor is worth 0.0.0 and everything clears 0.0.0. It is not + // silent, though: the manifest schema refuses it outright and the runtime + // probe logs a warning, so nobody believes a constraint is applying when it + // is not. + it('passes when the floor itself is unreadable, since it declares nothing', () => { expect(isVersionAtLeast('4.2.0', 'garbage')).toBe(true) - expect(isVersionAtLeast('4.2.0', 'v4')).toBe(true) + expect(isVersionAtLeast('4.2.0', 'next')).toBe(true) }) // Fails closed: an unidentifiable peer never clears a real floor. const UNIDENTIFIABLE: Array<[string | null | undefined, string]> = [ - ['v4', 'the legacy header'], ['dev', 'a source build'], ['garbage', 'a corrupt value'], + ['', 'a blank answer'], [null, 'an unreachable peer'], [undefined, 'a missing value'], ] @@ -154,6 +198,15 @@ describe('isVersionAtLeast', () => { it.each(UNIDENTIFIABLE)('fails when the candidate is %p (%s) and a real floor exists', (candidate) => { expect(isVersionAtLeast(candidate, '4.1.0')).toBe(false) }) + + // The legacy hardcoded header now parses (as 4.0.0) instead of being + // rejected as junk, and still loses — for the honest reason that 4.0.0 + // predates the floor rather than because the string looked odd. + it('reads the legacy "v4" header as 4.0.0, which still fails a 4.1.0 floor', () => { + expect(parseVersion('v4')).toEqual({ major: 4, minor: 0, patch: 0, prerelease: undefined }) + expect(isVersionAtLeast('v4', '4.1.0')).toBe(false) + expect(isVersionAtLeast('v4', '4.0.0')).toBe(true) + }) }) describe('compareSemver', () => { @@ -181,18 +234,28 @@ describe('compareSemver', () => { }) it('strips pre-release suffix before comparing', () => { - // The function intentionally ignores pre-release ordering; both compare - // as the same `4.1.1` triple. If we ever ship pre-releases for real this - // contract needs revisiting, but ignoring is the safer default today. expect(compareSemver('4.1.1-rc.1', '4.1.1')).toBe(0) expect(compareSemver('4.1.1+build.5', '4.1.1-rc.1')).toBe(0) }) - it('treats malformed inputs as 0.0.0 (defensive against corrupt manifests)', () => { + // A v-prefixed catalog version used to parse as 0.0.0 and sort below every + // plain-numbered release, so "update available" was wrong for it. + it('ranks a v-prefixed version by its number, not below everything', () => { + expect(compareSemver('v2.0.0', '1.0.0')).toBe(1) + expect(compareSemver('1.0.0', 'v2.0.0')).toBe(-1) + expect(compareSemver('v1.2.3', '1.2.3')).toBe(0) + }) + + // Sorting needs a total order, so this is the one place unknown becomes + // 0.0.0 — a corrupt `version` in somebody else's manifest sorts to the + // bottom instead of breaking the catalog. Nothing is gated on the result. + it('sorts an unreadable version as the lowest possible one', () => { expect(compareSemver('not-a-version', '0.0.0')).toBe(0) expect(compareSemver('', '0.0.0')).toBe(0) - expect(compareSemver('1.2', '1.2.0')).toBe(0) // missing patch defaults to 0 - expect(compareSemver('abc.def.ghi', '0.0.1')).toBe(-1) // bogus < 0.0.1 + expect(compareSemver('1.2', '1.2.0')).toBe(0) + expect(compareSemver('abc.def.ghi', '0.0.1')).toBe(-1) + expect(compareSemver('0.0.1', 'abc.def.ghi')).toBe(1) + expect(compareSemver('garbage', 'nonsense')).toBe(0) }) }) @@ -215,4 +278,19 @@ describe('isCompatibleEditorVersion', () => { expect(isCompatibleEditorVersion('5.0.0', '4.1.1')).toBe(false) expect(isCompatibleEditorVersion('4.2.0', '4.1.1')).toBe(false) }) + + // The install gate and the runtime gates must answer identically for every + // string, or the same floor is enforced in one place and ignored in the + // other. This is that contract, asserted directly. + it.each([ + ['4.3', '4.2.10'], + ['4', '4.2.10'], + ['v5', '4.2.10'], + ['4.2.10', '4.2.10'], + ['garbage', '4.2.10'], + ['', '4.2.10'], + ['99.0.0', '4.2.10'], + ])('agrees with isVersionAtLeast for floor %p against editor %p', (floor, current) => { + expect(isCompatibleEditorVersion(floor, current)).toBe(isVersionAtLeast(current, floor)) + }) }) diff --git a/src/frontend/utils/semver.ts b/src/frontend/utils/semver.ts index 39a14f2c6..846f09481 100644 --- a/src/frontend/utils/semver.ts +++ b/src/frontend/utils/semver.ts @@ -12,25 +12,23 @@ * * This file used to answer only #3, with `firmware/runtime-version-gate.ts` * carrying its own parser for the runtime side. The two disagreed on exactly - * the inputs that show up in the field: + * the inputs that show up in the field — `"v4"` and `"4.1"` parsed in one and + * were rejected by the other — and a first pass at unifying them kept that + * split alive as a lenient parser and a strict parser chosen by name. * - * input | catalog parser | runtime parser - * -------------|------------------|---------------- - * "v4" | 4.0.0 | rejected - * "4.1" | 4.1.0 | rejected - * "garbage" | 0.0.0 (lowest) | rejected + * That was still one parser too many. The same string has to mean the same + * thing everywhere, or a floor is enforced in one place and ignored in + * another: `minEditorVersion: "4.3"` refused an install while the identical + * value from a runtime sailed through unnoticed. So there is now exactly + * ONE parser, and it applies one rule: * - * Neither behaviour was wrong for its own caller. A package manifest carrying - * a corrupt version should not crash the catalog UI, and an unidentifiable - * runtime must not receive an upload. What was wrong is that the DIFFERENCE - * lived in two separate parsers, where nothing named it and nothing tested it - * side by side. + * - a `v` prefix is decoration: `v4.3.2` === `4.3.2` + * - a missing component is zero: `4.3` === `4.3.0`, `4` === `4.0.0` + * - anything else is UNKNOWN: `"dev"`, `"garbage"`, `""` → null * - * So: one parse, one comparison, and the lenient-vs-strict choice made - * explicitly by name at the call site. `parseVersionStrict` returns null for - * anything it cannot fully identify — callers that must fail closed use it. - * `parseVersionLenient` fills missing components with 0 and degrades garbage to - * 0.0.0 — callers rendering untrusted metadata use it. + * "Unknown" is never a version. It does not become 0.0.0 behind the caller's + * back and it never satisfies a declared floor — a peer that cannot say what + * it is does not get to claim it is new enough. * * Pre-release and build suffixes (`-rc.1`, `+build.5`) are parsed but do NOT * affect ordering: `4.1.0-rc.3` compares equal to `4.1.0`. This is deliberate @@ -52,48 +50,55 @@ export interface ParsedVersion { prerelease?: string } -/** `v4.1.0-rc.3` / `4.1.0` — all three numeric components required. */ -const STRICT_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+](.+))?$/ +/** + * `4`, `4.3`, `4.3.2`, `v4.3.2`, `4.3.2-rc.1`, `4.3.2+build.5`. + * + * Anchored at both ends on purpose: a trailing-garbage input like `"4.1 beta"` + * must fail rather than silently parse as `4.1.0`, because a value nobody can + * read is a mistake worth surfacing, not a version worth guessing. + */ +const VERSION_RE = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+](.+))?$/ + +/** Lowest possible version — what an unknown string is worth in a total order. */ +const ZERO: ParsedVersion = { major: 0, minor: 0, patch: 0 } /** - * Parse a version string, requiring all three numeric components. + * Parse a version string, or return null when the string is not a version. * - * Returns null for anything else — `"v4"`, `"4.1"`, `"dev"`, `""`, null. Use - * this when an unidentifiable version must block an action: the caller cannot - * accidentally treat "I don't know" as "old enough" or "new enough", because - * there is no number to compare. + * Missing trailing components are zero, so a floor written as `"4.3"` means + * exactly what `"4.3.0"` means and is enforced identically. A leading `v` is + * stripped. Everything else — `"dev"`, `"garbage"`, `""`, `"4.1 beta"`, null — + * is UNKNOWN, and callers decide what unknown costs them. */ -export function parseVersionStrict(raw: string | null | undefined): ParsedVersion | null { +export function parseVersion(raw: string | null | undefined): ParsedVersion | null { if (!raw) return null - const match = raw.trim().match(STRICT_RE) + const match = raw.trim().match(VERSION_RE) if (!match) return null return { major: Number.parseInt(match[1], 10), - minor: Number.parseInt(match[2], 10), - patch: Number.parseInt(match[3], 10), + minor: match[2] === undefined ? 0 : Number.parseInt(match[2], 10), + patch: match[3] === undefined ? 0 : Number.parseInt(match[3], 10), prerelease: match[4], } } +/** True when `raw` is a version this codebase can compare. */ +export function isValidVersion(raw: string | null | undefined): boolean { + return parseVersion(raw) !== null +} + /** - * Parse a version string, filling in whatever is missing with zero. + * A version string as it should appear in a message to the user: trimmed, or + * the word `unknown` when there is nothing readable to show. * - * `"4.1"` becomes 4.1.0; `"garbage"` and `""` become 0.0.0 — the lowest - * possible version, so a corrupt value loses every comparison instead of - * winning one. Use this for untrusted metadata being rendered rather than - * enforced, where a malformed field should degrade the display and not throw. + * Exists so that every "incompatible versions" message renders an unreadable + * peer the same way. Printing an empty string leaves a hole in the sentence + * ("Runtime on 10.0.0.1 requires…") and tells the user nothing about which + * of the two versions the editor failed to establish. */ -export function parseVersionLenient(raw: string | null | undefined): ParsedVersion { - // Deliberately unanchored at the end: it consumes as many leading numeric - // components as it finds and ignores whatever follows, so `4.1.9-rc.1` and - // `4.1` both parse without a separate suffix-stripping pass. - const match = (raw ?? '').trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/) - if (!match) return { major: 0, minor: 0, patch: 0 } - const toInt = (value: string | undefined): number => { - const parsed = Number.parseInt(value ?? '', 10) - return Number.isFinite(parsed) ? parsed : 0 - } - return { major: toInt(match[1]), minor: toInt(match[2]), patch: toInt(match[3]) } +export function formatVersionForDisplay(raw: string | null | undefined): string { + const trimmed = raw?.trim() ?? '' + return trimmed.length > 0 ? trimmed : 'unknown' } /** @@ -108,44 +113,48 @@ export function compareParsedVersions(a: ParsedVersion, b: ParsedVersion): -1 | } /** - * `candidate >= minimum`, where an unparseable `candidate` fails closed. + * `candidate >= minimum` — the one comparison every DOPE-448 gate asks. + * + * Three inputs and what each costs: * - * This is the shape every DOPE-448 gate wants: "may I proceed?" answered - * `false` when the peer cannot be identified. An absent `minimum` means no - * constraint was declared, which is a pass — a peer that asks for nothing gets - * nothing enforced, which is what keeps runtimes predating - * `/api/capabilities` working unchanged. + * - `minimum` absent or empty → **pass**. A peer that declares no floor + * constrains nothing; this is every runtime predating `/api/capabilities` + * and it is what makes shipping the gates safe. + * - `minimum` present but unreadable → **pass**, because an unknown floor is + * worth 0.0.0 and everything clears 0.0.0. Callers that can see the string + * should say so out loud rather than let it vanish — the manifest schema + * rejects such a value outright, and the runtime probe logs a warning. + * - `candidate` unreadable against a real floor → **fail**. An unknown + * version never satisfies a declared minimum. */ export function isVersionAtLeast(candidate: string | null | undefined, minimum: string | null | undefined): boolean { - if (!minimum) return true - const min = parseVersionStrict(minimum) - if (!min) return true // a floor we cannot read declares nothing - const version = parseVersionStrict(candidate) - if (!version) return false // an unidentifiable peer never clears a real floor + const min = parseVersion(minimum) + if (!min) return true + const version = parseVersion(candidate) + if (!version) return false return compareParsedVersions(version, min) >= 0 } -// --------------------------------------------------------------------------- -// Lenient VPP-surface helpers -// --------------------------------------------------------------------------- - /** - * Lenient comparison, used by the VPP catalog and the package install gate. + * Order two version strings for display purposes — sorting catalog rows, + * deciding whether an available version is newer than the installed one. * - * Lenient is right *here* specifically: a package manifest is untrusted - * third-party metadata, and a corrupt `version` string should sort as the - * lowest possible version rather than break a card in the catalog UI. Gates - * deciding whether to talk to a runtime use `isVersionAtLeast` instead. + * This is the ONE place an unknown version is coerced to 0.0.0, because a + * sortable list needs a total order and a corrupt `version` field in somebody + * else's manifest should sort to the bottom rather than break the UI. Nothing + * is gated on the result. Every gate uses `isVersionAtLeast`. */ export function compareSemver(a: string, b: string): -1 | 0 | 1 { - return compareParsedVersions(parseVersionLenient(a), parseVersionLenient(b)) + return compareParsedVersions(parseVersion(a) ?? ZERO, parseVersion(b) ?? ZERO) } /** - * True when `current` satisfies `minRequired`. An absent or empty minimum - * means the package declared no floor, which is a pass. + * True when `current` satisfies the `minRequired` a package declares. + * + * Delegates to `isVersionAtLeast` so the install gate and the runtime gates + * cannot disagree about what a given string means — the bug this consolidation + * exists to prevent. */ export function isCompatibleEditorVersion(minRequired: string | undefined, current: string): boolean { - if (!minRequired) return true - return compareSemver(current, minRequired) >= 0 + return isVersionAtLeast(current, minRequired) } diff --git a/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts b/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts new file mode 100644 index 000000000..b9fa4458d --- /dev/null +++ b/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts @@ -0,0 +1,66 @@ +import { PackageManifestSchema } from '../package-manifest-schema' + +/** A minimal manifest the schema accepts, with room to override `package`. */ +const manifest = (pkg: Record = {}) => ({ + formatVersion: '1.0', + package: { id: 'vendor.board', name: 'Vendor Board', version: '1.0.0', ...pkg }, + devices: [{ id: 'slm-rp4', name: 'SLM-RP4' }], +}) + +describe('PackageManifestSchema — compatibility floors', () => { + it('accepts a manifest that declares no floors at all', () => { + // Packages built before DOPE-448 must keep installing. + expect(PackageManifestSchema.safeParse(manifest()).success).toBe(true) + }) + + // These are the shorthands a human writes by hand. Each means exactly its + // zero-filled equivalent and is enforced as such, so accepting them here is + // not leniency — it is the same rule the comparator applies. + it.each([ + ['4.3.2', 'a full triple'], + ['v4.3.2', 'a tag-style v prefix'], + ['4.3', 'a two-part shorthand'], + ['4', 'a bare major'], + ['v5', 'a v-prefixed bare major'], + ['4.3.2-rc.1', 'a pre-release'], + ['4.3.2+build.5', 'a build suffix'], + ])('accepts minEditorVersion %p (%s)', (minEditorVersion) => { + expect(PackageManifestSchema.safeParse(manifest({ minEditorVersion })).success).toBe(true) + }) + + // The reason this check exists: an unreadable floor is not inert. The + // comparator treats it as "declares nothing", so the package installs + // everywhere while its author believes a constraint is being enforced. + // A sideloaded `.vpp` never passes through openplc-packages' validator, so + // for that entry path this schema is the only boundary there is. + it.each([ + ['garbage', 'a non-version word'], + ['next', 'a channel name mistaken for a version'], + ['4,3,0', 'the wrong separator'], + ['4.3 or newer', 'prose'], + [' ', 'whitespace only'], + ['', 'an empty string'], + ])('rejects minEditorVersion %p (%s)', (minEditorVersion) => { + expect(PackageManifestSchema.safeParse(manifest({ minEditorVersion })).success).toBe(false) + }) + + it('applies the same rule to minRuntimeVersion', () => { + expect(PackageManifestSchema.safeParse(manifest({ minRuntimeVersion: '4.2' })).success).toBe(true) + expect(PackageManifestSchema.safeParse(manifest({ minRuntimeVersion: 'garbage' })).success).toBe(false) + }) + + it('names the offending field so the install error is actionable', () => { + const result = PackageManifestSchema.safeParse(manifest({ minEditorVersion: 'garbage' })) + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.issues[0].path).toEqual(['package', 'minEditorVersion']) + expect(result.error.issues[0].message).toContain('must be a version') + }) + + it('still lets unknown fields through untouched', () => { + // The editor stays agnostic to manifest contents; the floors are the + // deliberate exception, not a new general policy. + const result = PackageManifestSchema.safeParse(manifest({ vendorExtension: { anything: true } })) + expect(result.success).toBe(true) + }) +}) diff --git a/src/middleware/shared/ports/package-manifest-schema.ts b/src/middleware/shared/ports/package-manifest-schema.ts index ef23107b9..ba9cd4d86 100644 --- a/src/middleware/shared/ports/package-manifest-schema.ts +++ b/src/middleware/shared/ports/package-manifest-schema.ts @@ -34,8 +34,28 @@ import { z } from 'zod' +import { isValidVersion } from '../../../frontend/utils/semver' import type { PackageManifest } from './types' +/** + * A compatibility floor must be a version this codebase can compare. + * + * This is the exception to the "the editor is agnostic to manifest + * contents" rule above, and it earns it: an unreadable floor is not + * inert, it is a constraint the package author believes is being + * enforced and which silently is not. `"4.3"`, `"4"` and `"v5"` are all + * accepted — they mean 4.3.0 / 4.0.0 / 5.0.0 — so in practice only + * genuine junk (`"garbage"`, `"next"`, `"4,3,0"`) is refused. + * + * It matters most for the path this gate exists for: a `.vpp` added + * from disk never passes through openplc-packages' `scripts/validate.ts`, + * so for sideloaded packages this schema is the only boundary there is. + */ +const versionFloor = z + .string() + .min(1) + .refine(isValidVersion, { message: 'must be a version like "4.3.2", "4.3", "4" or "v4.3.2"' }) + export const PackageManifestSchema = z .object({ formatVersion: z.string().min(1), @@ -53,8 +73,10 @@ export const PackageManifestSchema = z // Authoring-side rules (minRuntimeVersion required iff a device targets // runtime-v4, rejected otherwise) live in openplc-packages' // `scripts/validate.ts`, per this file's split of responsibilities. - minEditorVersion: z.string().min(1).optional(), - minRuntimeVersion: z.string().min(1).optional(), + // The *format* is checked here because a floor nobody can parse is a + // constraint that silently does not apply — see `versionFloor`. + minEditorVersion: versionFloor.optional(), + minRuntimeVersion: versionFloor.optional(), }) .passthrough(), devices: z.array(z.object({}).passthrough()).min(1), From 097a336757412142759ae7751c836b981c6dabc5 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 6 Aug 2026 17:44:29 -0400 Subject: [PATCH 64/79] docs(compat): mark review items 3, 4 and 6 resolved (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §10 items 3 (unreadable floor discarded silently), 4 (manifest accepts a malformed floor) and 6 (hand-rolled comparators) were fixed in the same PR; strike them through with what actually landed rather than leaving a "still open" list that is no longer true. Item 6 said "three" and named two — there were two. Also records the sharper reason for 6 that only surfaced while fixing it: the hand-rolled bodies hardcoded the shape of the constant beside them, so raising a floor to a non-.0 patch would have left them answering for the old one with every test still green. §6's edge-case table gains the two rows the fix created — a partial floor (`"4.3"`) is enforced as `4.3.0` rather than being ignored, and an unreadable floor now warns — and drops the claim that no warning exists. §8 phase 1 records that the first cut shipped two parsers and why one was too many, plus the `"v4"` parsing change. Splits out item 7: `minRuntimeVersion` is still unenforced at install time for sideloaded packages. Items 5 and 7 remain open and need tickets. Co-Authored-By: Claude Opus 5 (1M context) --- docs/version-compatibility-strategy.md | 113 ++++++++++++++++--------- 1 file changed, 75 insertions(+), 38 deletions(-) diff --git a/docs/version-compatibility-strategy.md b/docs/version-compatibility-strategy.md index 29fb91ad1..a70e9bb0d 100644 --- a/docs/version-compatibility-strategy.md +++ b/docs/version-compatibility-strategy.md @@ -279,22 +279,29 @@ supported state rather than an error. | Peer state | Behaviour as implemented | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Runtime without `/api/capabilities` | no editor floor to check; `MIN_RUNTIME_VERSION` gate still applies; **silent** — see below | -| Runtime version unparseable (`"v4"`, `"dev"`) | fail closed — previous behaviour, unchanged | -| Runtime declares an _unreadable_ floor (`"4.2"`, junk) | floor ignored, upload proceeds, **no signal** — known gap, see §10 | +| Runtime version unreadable (`"dev"`, junk) | fail closed — previous behaviour, unchanged | +| Runtime declares a partial floor (`"4.3"`, `"4"`) | enforced as `4.3.0` / `4.0.0` — a missing component is zero everywhere | +| Runtime declares an _unreadable_ floor (junk) | floor ignored, upload proceeds, **warning logged** to the compile console | | VPP without `minRuntimeVersion` on a `runtime-v4` target | install allowed, runtime match unverifiable, no warning; blocked at package build time instead | +| VPP with an _unreadable_ floor | manifest rejected at install — the schema refuses a floor it cannot parse | The first row is silent **on purpose**: a runtime with no `/api/capabilities` is every device currently deployed, so warning there would fire on every upload from -every editor. The third row is different — a floor that is present but malformed +every editor. The fourth row is different — a floor that is present but malformed is a mistake someone made, and swallowing it means a constraint the runtime -believes it is enforcing silently is not. That one deserves a warning and does -not have one yet. +believes it is enforcing silently is not. That one now warns. + +Note the third row: `"4.3"` is **not** an unreadable floor. A missing component +is zero throughout the codebase, so a hand-written shorthand is enforced exactly +as its zero-filled equivalent. Only genuine junk reaches the fourth row, which is +why refusing it in a manifest costs nothing. Because the runtime never enforces, no closing window is needed on the upload -path: an old runtime simply contributes no constraint. The only future tightening -worth scheduling is making `minRuntimeVersion` mandatory for `runtime-v4` -packages, which is enforced at **package build time** (`scripts/validate.ts`) and -so never breaks an installed package. +path: an old runtime simply contributes no constraint. Making `minRuntimeVersion` +mandatory for `runtime-v4` packages already ships, enforced at **package build +time** (`scripts/validate.ts`), so it never breaks an installed package. The +remaining gap is install-time enforcement for sideloaded packages, which never +pass through that validator — see §10. ## 7. Accepted limitation @@ -325,10 +332,25 @@ cannot break a peer. | 6 | Tests | editor + web | openplc-editor#993 · openplc-web#652 | **Phase 1 — one semver parser.** `frontend/utils/semver.ts` owns the parse and -the ordering; `parseRuntimeVersion` and `compareSemver` became thin wrappers, so -no call site changed and existing tests passed untouched. It lives in -`frontend/utils/` rather than `backend/shared/` because the layer rules allow -`backend-shared → utils` and not the reverse. +the ordering; `parseRuntimeVersion` and `compareSemver` are thin wrappers over +it. It lives in `frontend/utils/` rather than `backend/shared/` because the layer +rules allow `backend-shared → utils` and not the reverse. + +The first cut of this kept _two_ parsers — a strict one and a lenient one, chosen +by name at the call site — which review showed was still one too many: the same +string meant different things depending on which a caller reached for, and that +is the defect the phase existed to remove (see §10 items 3 and 6). There is now +exactly one `parseVersion`, applying one rule: a `v` prefix is decoration, a +missing component is zero, anything else is UNKNOWN (`null`). Unknown never +becomes `0.0.0` behind a caller's back, so a gate can still distinguish "I cannot +read this" from "this is old" and fail closed on the first. The one place a total +order is genuinely required — sorting catalog rows — coerces unknown to `0.0.0` +explicitly, inside `compareSemver`, where nothing is gated on the result. + +Consequence worth recording: the legacy `"v4"` header now parses as `4.0.0` +instead of being rejected as unreadable. No gate's answer changes — `4.0.0` is +below `MIN_RUNTIME_VERSION` either way — but it is refused because it is old, +not because the string looked odd. **Phase 2 — VPP install gate.** `package-manager-module.ts::install` compares `minEditorVersion` against `APP_VERSION`, next to the signature verification, so @@ -388,34 +410,49 @@ Considered and dropped, so nobody re-derives them: 2. ~~**Warning surface for a runtime that declares nothing?**~~ → **silent**, for the reason in §6: that is every deployed device. +3. ~~**An unreadable floor is discarded with no signal.**~~ → **fixed** + (openplc-editor#993 · openplc-web#652). Two halves. The one that mattered in + practice was that `"4.2"` was not "unreadable" in one gate and was in another: + the install gate honoured it as 4.2.0 while `isVersionAtLeast` dropped it. A + missing component is now zero everywhere, so a partial floor is enforced + exactly as its zero-filled equivalent, and `isCompatibleEditorVersion` + delegates to `isVersionAtLeast` so the two gates cannot disagree at all. What + remains genuinely unreadable is junk, and `probe-runtime-version.ts` logs a + warning when a runtime publishes it. The upload still proceeds — refusing a + device over a typo in its metadata would be worse — but a constraint the + runtime believes it is enforcing can no longer go missing in silence. +4. ~~**A malformed floor in a VPP manifest becomes "no floor".**~~ → **fixed** + (openplc-editor#993 · openplc-web#652). `package-manifest-schema.ts` refuses a + `minEditorVersion` / `minRuntimeVersion` it cannot parse, naming the field in + the error. As predicted, the cost is nil: `"4.3"`, `"4"`, `"v5"` and + pre-release suffixes all pass, so only genuine junk changes behaviour. This is + the only boundary a sideloaded `.vpp` crosses, which is the entry path the + install gate exists for. +5. ~~**Hand-rolled comparators remain**~~ → **fixed** (openplc-editor#993 · + openplc-web#652). `isStrucppCompatibleRuntime` and + `isUserManagementCapableRuntime` are now `isVersionAtLeast(raw, )`. + (The item said "three" and named two — there were two.) The equivalence was + verified exhaustively before the swap, but the reason to do it turned out to + be sharper than duplication: the hand-rolled bodies hardcoded the _shape_ of + the constant beside them. `return v.minor >= 1` is correct only because the + floor ends in `.0`; raise `MIN_RUNTIME_VERSION` to `4.1.5` and it keeps + admitting 4.1.0 while every behavioural test still passes, because those tests + were written against the old floor too. Guard tests now derive their + expectations from the constants, so a re-inlined comparison fails immediately. + **Still open** -3. **An unreadable floor is discarded with no signal.** `isVersionAtLeast` - returns `true` when it cannot parse the _minimum_, so - `minEditorVersion: "4.2"` — a plausible hand-written shorthand — disables the - gate entirely and says nothing. The asymmetry is the problem: the same string - is fatal as the _candidate_, with a user-visible message, and invisible as the - _floor_. The log channel is already threaded through the probe; this needs one - warning. No test can catch it today because the symptom is the absence of a - symptom. -4. **A malformed floor in a VPP manifest becomes "no floor".** - `package-manifest-schema.ts` accepts any non-empty string and the install gate - compares leniently, so `minEditorVersion: "garbage"` installs anywhere. - `openplc-packages`' `scripts/validate.ts` covers published packages, but the - install gate exists precisely because a sideloaded `.vpp` never passes through - that validator — for that entry path this schema is the only boundary. Cost of - requiring a strict `x.y.z` is nil: `"4.3"` and `"v5"` are already honoured, so - only total junk changes behaviour. 5. **An installed-but-incompatible VPP keeps loading after an editor downgrade** (§4). Re-checking `minEditorVersion` on load would close it. Open sub-question if we do: hide the package, or show it as unusable? Hiding is cleaner; showing explains why a board disappeared. -6. **Three hand-rolled comparators remain** in `runtime-version-gate.ts` - (`isStrucppCompatibleRuntime`, `isUserManagementCapableRuntime`) doing their - own `if (v.major > 4) …` next to the constants they compare. Both are exactly - equivalent to `isVersionAtLeast(raw, )`, `null` handling included. - Not a bug — the same duplication this work set out to remove, one level up: - the parser got unified, the comparators did not. - -Items 3–6 were raised in review of openplc-editor#993. Items 3, 4 and 6 touch -shared-surface files, so any fix needs the mirrored commit in openplc-web. +6. **`minRuntimeVersion` is not enforced at install time for sideloaded + packages.** `scripts/validate.ts` requires it for `runtime-v4` packages at + authoring time, and the compile pipeline compares it against the connected + runtime — but a `.vpp` added from disk declaring no floor installs and only + fails later, at compile. Lower priority than 5: the compile-time gate catches + the mismatch before anything reaches a device. + +Items 3–6 were raised in review of openplc-editor#993; 3, 4 and 6 were fixed in +the same PR (mirrored in openplc-web#652, both on shared-surface files). Item 7 +was split out of CodeRabbit's read of §6. Items 5 and 7 need their own tickets. From 72d2ea0584ee2f0939c2cd75a0dc793f0c4635ba Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 6 Aug 2026 17:48:01 -0400 Subject: [PATCH 65/79] fix(compat): reject version components too large to hold exactly (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Number.parseInt` answers a *finite* number for a 17-digit component and quietly rounds it: `"9007199254740993"` comes back as `…992`. The parser would then hand callers a version that no longer matches the string it came from, and `isVersionAtLeast` would compare against it. That breaks the only promise this parser makes — readable means exact, anything else is UNKNOWN. A component we can only approximate is not readable, so `parseVersion` now returns null for it, and such a version gets the same treatment as any other unreadable string: fails closed as a candidate, declares nothing as a floor. Unreachable with any real version string; taken because the rule is cheaper to state without an exception than with one. Raised by CodeRabbit on openplc-editor#993. Co-Authored-By: Claude Opus 5 (1M context) --- src/frontend/utils/__tests__/semver.test.ts | 20 ++++++++++++++ src/frontend/utils/semver.ts | 30 ++++++++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/frontend/utils/__tests__/semver.test.ts b/src/frontend/utils/__tests__/semver.test.ts index ff0dc5b33..ff301864d 100644 --- a/src/frontend/utils/__tests__/semver.test.ts +++ b/src/frontend/utils/__tests__/semver.test.ts @@ -63,6 +63,26 @@ describe('parseVersion', () => { expect(parseVersion(null)).toBeNull() expect(parseVersion(undefined)).toBeNull() }) + + // `Number.parseInt` answers a finite number here and quietly rounds it — + // 9007199254740993 comes back as …992. A component we can only approximate is + // not readable, and returning a number that no longer matches the string it + // came from would break the one promise the parser makes. + it('rejects a component too large to hold exactly', () => { + expect(parseVersion('9007199254740993.0.0')).toBeNull() + expect(parseVersion('4.9007199254740993.0')).toBeNull() + expect(parseVersion('4.1.9007199254740993')).toBeNull() + // The largest value that IS exact still parses. + expect(parseVersion('9007199254740991.0.0')?.major).toBe(9007199254740991) + }) + + it('leaves an oversized version unable to clear any floor', () => { + // Fails closed as a candidate, declares nothing as a floor — the same + // treatment every other unreadable string gets. + expect(isVersionAtLeast('9007199254740993.0.0', '4.1.0')).toBe(false) + expect(isVersionAtLeast('4.1.0', '9007199254740993.0.0')).toBe(true) + expect(isValidVersion('9007199254740993.0.0')).toBe(false) + }) }) describe('isValidVersion', () => { diff --git a/src/frontend/utils/semver.ts b/src/frontend/utils/semver.ts index 846f09481..62770e383 100644 --- a/src/frontend/utils/semver.ts +++ b/src/frontend/utils/semver.ts @@ -26,6 +26,10 @@ * - a missing component is zero: `4.3` === `4.3.0`, `4` === `4.0.0` * - anything else is UNKNOWN: `"dev"`, `"garbage"`, `""` → null * + * "Readable" means *exactly* readable: a component too large to hold without + * rounding is UNKNOWN too, rather than a number that no longer matches the + * string it came from. + * * "Unknown" is never a version. It does not become 0.0.0 behind the caller's * back and it never satisfies a declared floor — a peer that cannot say what * it is does not get to claim it is new enough. @@ -62,6 +66,21 @@ const VERSION_RE = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+](.+))?$/ /** Lowest possible version — what an unknown string is worth in a total order. */ const ZERO: ParsedVersion = { major: 0, minor: 0, patch: 0 } +/** + * A single numeric component, or null when it is not one we can hold exactly. + * + * `Number.parseInt` answers a *finite* number for a 17-digit component and + * quietly rounds it: `"9007199254740993"` comes back as `…992`. That breaks the + * only promise this parser makes — readable means exact, and anything else is + * UNKNOWN. A component we can only approximate is not readable, so it is null + * rather than a number that no longer matches what was written. + */ +function parseComponent(raw: string | undefined): number | null { + if (raw === undefined) return 0 + const value = Number.parseInt(raw, 10) + return Number.isSafeInteger(value) ? value : null +} + /** * Parse a version string, or return null when the string is not a version. * @@ -74,12 +93,11 @@ export function parseVersion(raw: string | null | undefined): ParsedVersion | nu if (!raw) return null const match = raw.trim().match(VERSION_RE) if (!match) return null - return { - major: Number.parseInt(match[1], 10), - minor: match[2] === undefined ? 0 : Number.parseInt(match[2], 10), - patch: match[3] === undefined ? 0 : Number.parseInt(match[3], 10), - prerelease: match[4], - } + const major = parseComponent(match[1]) + const minor = parseComponent(match[2]) + const patch = parseComponent(match[3]) + if (major === null || minor === null || patch === null) return null + return { major, minor, patch, prerelease: match[4] } } /** True when `raw` is a version this codebase can compare. */ From 5ef73f83cc0d045123f892ed56c3c0e8ec545ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 7 Aug 2026 17:58:00 +0200 Subject: [PATCH 66/79] fix(compat): tolerate an unreadable version floor on the load path (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor-format rule added to `PackageManifestSchema` guards the boundary where a package ENTERS the editor. The same schema is also used to read the `manifest.json` of an already-installed package, and there the rule had a second-order effect nobody wants: a package installed by an older editor, carrying a floor only genuine junk could produce, would stop parsing on load. `getInstalledPackageManifest` returns null, the boards it provides fall out of the board lookup, and they do so silently — on an upgrade where the user did nothing. Split the two: `parseInstalledPackageManifest` drops a floor this codebase cannot compare and logs it; `importFromFile` still refuses one outright. Dropping costs nothing that was not already lost, since an unreadable floor never gated anything (`isVersionAtLeast` treats it as "declares nothing"), and the log keeps the cause visible instead of trading one invisible outcome for another. Tolerance is scoped to the floors — a document that is not a manifest still rejects. Applied at both read sites: the main process (`getInstalledPackageManifest`) and the renderer adapter that re-validates what comes back over IPC. Raised in re-review of #993; recorded in §10 item 4 of the strategy doc. Mirrored byte-identically in openplc-web. Co-Authored-By: Claude Opus 5 --- docs/version-compatibility-strategy.md | 17 +++- .../get-installed-package-manifest.test.ts | 88 +++++++++++++++++++ .../package-manager/package-manager-module.ts | 14 ++- src/frontend/utils/semver.ts | 3 +- .../editor/__tests__/package-adapter.test.ts | 18 ++++ .../adapters/editor/package-adapter.ts | 9 +- .../__tests__/package-manifest-schema.test.ts | 76 +++++++++++++++- .../shared/ports/package-manifest-schema.ts | 65 ++++++++++++++ 8 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 src/backend/editor/package-manager/__tests__/get-installed-package-manifest.test.ts diff --git a/docs/version-compatibility-strategy.md b/docs/version-compatibility-strategy.md index a70e9bb0d..99304a838 100644 --- a/docs/version-compatibility-strategy.md +++ b/docs/version-compatibility-strategy.md @@ -355,7 +355,9 @@ not because the string looked odd. **Phase 2 — VPP install gate.** `package-manager-module.ts::install` compares `minEditorVersion` against `APP_VERSION`, next to the signature verification, so one gate covers both the catalog and the "Add from file…" path. -`openplc-packages/docs/package-format.md:69` is now true. +`openplc-packages/docs/package-format.md:69` is now true. Reading an installed +package's manifest back off disk goes through `parseInstalledPackageManifest` +instead, which tolerates a floor it cannot compare — see §10 item 4. **Phase 3 — `minRuntimeVersion` in the manifest.** Field added to the schema, conditionally required when any device targets `runtime-v4` and rejected @@ -428,6 +430,19 @@ Considered and dropped, so nobody re-derives them: pre-release suffixes all pass, so only genuine junk changes behaviour. This is the only boundary a sideloaded `.vpp` crosses, which is the entry path the install gate exists for. + + Second-order effect, raised in re-review and fixed with it: the same schema is + also used to read the `manifest.json` of an **already-installed** package, so + the new format rule would have made a package installed before this change — + carrying a floor only genuine junk could produce — stop resolving on load, and + its boards disappear from the board lookup with no message, on an upgrade + where the user did nothing. The rule is therefore strict where the artefact + **enters** and tolerant where we are only **reading** what is already on disk: + `parseInstalledPackageManifest` drops an uncomparable floor and logs it, the + install path still refuses it. Dropping costs nothing that was not already + lost — an unreadable floor never gated anything — and refusing stays where + refusing belongs. + 5. ~~**Hand-rolled comparators remain**~~ → **fixed** (openplc-editor#993 · openplc-web#652). `isStrucppCompatibleRuntime` and `isUserManagementCapableRuntime` are now `isVersionAtLeast(raw, )`. diff --git a/src/backend/editor/package-manager/__tests__/get-installed-package-manifest.test.ts b/src/backend/editor/package-manager/__tests__/get-installed-package-manifest.test.ts new file mode 100644 index 000000000..0cf94653a --- /dev/null +++ b/src/backend/editor/package-manager/__tests__/get-installed-package-manifest.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { app } from 'electron' + +// Same trimming as the signature suite: this module pulls in winston transports +// and extract-zip's ESM entry point that this suite has no use for. +jest.mock('electron', () => ({ app: { getPath: jest.fn(() => '/mock/path') } })) +jest.mock('extract-zip', () => ({ __esModule: true, default: jest.fn() })) +jest.mock('../../services/logger-service', () => ({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, +})) + +import { PackageManagerModule } from '../package-manager-module' + +/** + * `getInstalledPackageManifest` is the LOAD path, and every board provided by a + * VPP resolves through it (`find-vpp-device` → `board-info-resolver` → the + * compile pipeline). The install gate is allowed to refuse; this is not, beyond + * a document that is not a manifest at all — a rejection here is a board + * disappearing from the lookup with nothing said to the user. + */ +describe('PackageManagerModule.getInstalledPackageManifest — reading an installed package', () => { + let userDataDir: string + let packagesDir: string + let warnSpy: jest.SpyInstance + + const manifestJson = (pkg: Record) => + JSON.stringify({ + formatVersion: '1.0', + package: { id: 'vendor.board', name: 'Vendor Board', version: '1.0.0', ...pkg }, + devices: [{ id: 'slm-rp4', name: 'SLM-RP4' }], + }) + + /** Write a package directory + registry entry, as an install would leave it. */ + function install(packageId: string, manifest: string): void { + const dir = join(packagesDir, packageId) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'manifest.json'), manifest) + writeFileSync( + join(packagesDir, 'registry.json'), + JSON.stringify({ + formatVersion: '1.0', + packages: { + [packageId]: { version: '1.0.0', installedAt: '2026-01-01T00:00:00Z', path: dir, devices: ['slm-rp4'] }, + }, + }), + ) + } + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'pkg-read-')) + packagesDir = join(userDataDir, 'packages') + ;(app.getPath as jest.Mock).mockReturnValue(userDataDir) + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + jest.clearAllMocks() + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('returns the manifest of a package installed with a well-formed floor', () => { + install('vendor.board', manifestJson({ minEditorVersion: '4.3' })) + const manifest = new PackageManagerModule().getInstalledPackageManifest('vendor.board') + expect(manifest?.package.minEditorVersion).toBe('4.3') + }) + + it('keeps resolving a package whose stored floor this editor cannot compare', () => { + // The regression this guards: such a package was installed by an editor + // predating the format check (DOPE-448). Rejecting its manifest on load + // would unresolve its boards on upgrade, with no message anywhere. + install('vendor.board', manifestJson({ minEditorVersion: 'nightly' })) + + const manifest = new PackageManagerModule().getInstalledPackageManifest('vendor.board') + + expect(manifest?.package.id).toBe('vendor.board') + expect(manifest?.package).not.toHaveProperty('minEditorVersion') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('minEditorVersion')) + }) + + it('still returns null for a document that is not a manifest', () => { + install('vendor.board', JSON.stringify({ nothing: 'useful' })) + expect(new PackageManagerModule().getInstalledPackageManifest('vendor.board')).toBeNull() + }) +}) diff --git a/src/backend/editor/package-manager/package-manager-module.ts b/src/backend/editor/package-manager/package-manager-module.ts index 1b3bad1ba..8df5fde12 100644 --- a/src/backend/editor/package-manager/package-manager-module.ts +++ b/src/backend/editor/package-manager/package-manager-module.ts @@ -5,7 +5,10 @@ import { join } from 'path' import { APP_VERSION } from '../../../frontend/data/constants/app-version' import { isCompatibleEditorVersion } from '../../../frontend/utils/semver' -import { PackageManifestSchema } from '../../../middleware/shared/ports/package-manifest-schema' +import { + PackageManifestSchema, + parseInstalledPackageManifest, +} from '../../../middleware/shared/ports/package-manifest-schema' import type { VppDeviceMatch } from '../../shared/hardware/find-vpp-device' import { findVppDeviceByBoardName } from '../../shared/hardware/find-vpp-device' import { validatePathId } from '../../shared/utils/path-safety' @@ -294,8 +297,13 @@ class PackageManagerModule { } catch { return null } - const parsed = PackageManifestSchema.safeParse(raw) - return parsed.success ? (parsed.data as unknown as PackageManifest) : null + // Read path, not the trust boundary: `importFromFile` above is where a + // manifest is refused. Here the package is already installed, and a + // manifest that was accepted by an older editor — one whose schema did + // not yet check the floor format (DOPE-448) — must keep resolving, or the + // boards it provides vanish from the board lookup with no message. An + // unreadable floor is dropped and logged; everything else still rejects. + return parseInstalledPackageManifest(raw) } getPackagePath(packageId: string): string | null { diff --git a/src/frontend/utils/semver.ts b/src/frontend/utils/semver.ts index 62770e383..70e0716de 100644 --- a/src/frontend/utils/semver.ts +++ b/src/frontend/utils/semver.ts @@ -141,7 +141,8 @@ export function compareParsedVersions(a: ParsedVersion, b: ParsedVersion): -1 | * - `minimum` present but unreadable → **pass**, because an unknown floor is * worth 0.0.0 and everything clears 0.0.0. Callers that can see the string * should say so out loud rather than let it vanish — the manifest schema - * rejects such a value outright, and the runtime probe logs a warning. + * rejects such a value when a package is installed and logs it when one + * already installed is read back, and the runtime probe logs a warning. * - `candidate` unreadable against a real floor → **fail**. An unknown * version never satisfies a declared minimum. */ diff --git a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts index 2e28e163e..b75d7748d 100644 --- a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts @@ -126,6 +126,24 @@ describe('createEditorPackageAdapter', () => { expect(await adapter.getManifest('missing')).toBeNull() }) + it('keeps the manifest usable when its stored compatibility floor is unreadable', async () => { + // Read path, not the install boundary (DOPE-448): a package installed + // before the floor format was checked must still render and still + // provide its boards. The floor is dropped with a log, not the manifest. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + ;(window.bridge.getPackageManifest as jest.Mock).mockResolvedValue({ + ...validManifest, + package: { ...validManifest.package, minEditorVersion: 'nightly' }, + }) + + const result = await adapter.getManifest('acme-controller') + + expect(result?.package.id).toBe('acme-controller') + expect(result?.package).not.toHaveProperty('minEditorVersion') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('minEditorVersion')) + warnSpy.mockRestore() + }) + it('returns null when the bridge returns a malformed manifest (zod rejection)', async () => { // Suppress the validation warning the schema emits; we want to // assert the rejection, not the noise. diff --git a/src/middleware/adapters/editor/package-adapter.ts b/src/middleware/adapters/editor/package-adapter.ts index 7d4672dea..0d3caeded 100644 --- a/src/middleware/adapters/editor/package-adapter.ts +++ b/src/middleware/adapters/editor/package-adapter.ts @@ -20,7 +20,7 @@ * importFromFile`) can run unchanged. */ -import { parsePackageManifest } from '../../shared/ports/package-manifest-schema' +import { parseInstalledPackageManifest } from '../../shared/ports/package-manifest-schema' import type { PackagePort } from '../../shared/ports/package-port' import type { ImportResult, @@ -92,9 +92,14 @@ export function createEditorPackageAdapter(): PackagePort { // manifest.json. Validate the shape here before handing it to UI // code — drift between port type and on-disk JSON is a real risk // that an unchecked cast would silently absorb. + // + // Same read-path tolerance as the main process applies (DOPE-448): this + // manifest belongs to an installed package, so an unreadable + // compatibility floor is dropped rather than taking the whole manifest — + // and the package's boards — down with it. const raw = await window.bridge.getPackageManifest(packageId) if (raw === null || raw === undefined) return null - return parsePackageManifest(raw) + return parseInstalledPackageManifest(raw) }, async listRemoteCatalog(): Promise { diff --git a/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts b/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts index b9fa4458d..e1ae083c3 100644 --- a/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts +++ b/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts @@ -1,4 +1,4 @@ -import { PackageManifestSchema } from '../package-manifest-schema' +import { PackageManifestSchema, parseInstalledPackageManifest } from '../package-manifest-schema' /** A minimal manifest the schema accepts, with room to override `package`. */ const manifest = (pkg: Record = {}) => ({ @@ -64,3 +64,77 @@ describe('PackageManifestSchema — compatibility floors', () => { expect(result.success).toBe(true) }) }) + +// The other half of the same rule: strict where a package ENTERS the editor, +// tolerant where an installed one is READ BACK. Without this split, the format +// check above would retroactively unresolve a package installed by an older +// editor — its boards would vanish from the board lookup with no message, on an +// upgrade where the user did nothing. +describe('parseInstalledPackageManifest — the load path', () => { + let warnSpy: jest.SpyInstance + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('reads a well-formed manifest exactly as the strict parser does', () => { + const parsed = parseInstalledPackageManifest(manifest({ minEditorVersion: '4.3' })) + expect(parsed?.package.minEditorVersion).toBe('4.3') + expect(warnSpy).not.toHaveBeenCalled() + }) + + it.each([ + ['minEditorVersion', 'garbage'], + ['minRuntimeVersion', '4,3,0'], + ])('drops an uncomparable %s and keeps the package readable', (field, value) => { + const parsed = parseInstalledPackageManifest(manifest({ [field]: value })) + + // The package still resolves — this is the whole point — and the floor it + // could never have enforced is simply gone. + expect(parsed?.package.id).toBe('vendor.board') + expect(parsed?.package).not.toHaveProperty(field) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(field)) + }) + + it('drops a floor that is not even a string', () => { + const parsed = parseInstalledPackageManifest(manifest({ minEditorVersion: 43 })) + expect(parsed?.package).not.toHaveProperty('minEditorVersion') + }) + + it('drops only the unreadable floor, leaving the readable one enforced', () => { + const parsed = parseInstalledPackageManifest(manifest({ minEditorVersion: 'garbage', minRuntimeVersion: '4.2' })) + expect(parsed?.package).not.toHaveProperty('minEditorVersion') + expect(parsed?.package.minRuntimeVersion).toBe('4.2') + }) + + it('leaves every other field of the package untouched', () => { + const parsed = parseInstalledPackageManifest( + manifest({ minEditorVersion: 'garbage', vendorExtension: { anything: true } }), + ) + expect(parsed?.package).toMatchObject({ + id: 'vendor.board', + name: 'Vendor Board', + version: '1.0.0', + vendorExtension: { anything: true }, + }) + }) + + it('still rejects a manifest that is malformed for any other reason', () => { + // Tolerance is scoped to the floors. A document missing `devices` is not a + // manifest, and reading it as one would crash deeper in the loader. + expect(parseInstalledPackageManifest({ formatVersion: '1.0', package: { id: 'x' } })).toBeNull() + }) + + it.each([ + ['a non-object', 'not a manifest'], + ['null', null], + ['an array', []], + ['a manifest whose package is not an object', { formatVersion: '1.0', package: 'nope', devices: [{ id: 'a' }] }], + ])('passes %s straight to the schema rather than guessing at it', (_label, value) => { + expect(parseInstalledPackageManifest(value)).toBeNull() + }) +}) diff --git a/src/middleware/shared/ports/package-manifest-schema.ts b/src/middleware/shared/ports/package-manifest-schema.ts index ba9cd4d86..fb89d961f 100644 --- a/src/middleware/shared/ports/package-manifest-schema.ts +++ b/src/middleware/shared/ports/package-manifest-schema.ts @@ -50,6 +50,11 @@ import type { PackageManifest } from './types' * It matters most for the path this gate exists for: a `.vpp` added * from disk never passes through openplc-packages' `scripts/validate.ts`, * so for sideloaded packages this schema is the only boundary there is. + * + * Refusing applies to the artefact ENTERING the editor. Reading a + * package that is already installed goes through + * `parseInstalledPackageManifest` below, which drops such a floor + * instead of rejecting the manifest around it. */ const versionFloor = z .string() @@ -102,3 +107,63 @@ export function parsePackageManifest(value: unknown): PackageManifest | null { // validation in openplc-packages for the deeper fields. return parsed.data as unknown as PackageManifest } + +/** The manifest fields `versionFloor` guards, as read by the load path. */ +const FLOOR_FIELDS: readonly string[] = ['minEditorVersion', 'minRuntimeVersion'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A floor that is absent constrains nothing; one that is present must be comparable. */ +function isUsableFloor(value: unknown): boolean { + return value === undefined || (typeof value === 'string' && isValidVersion(value)) +} + +/** + * Return `value` with any compatibility floor this codebase cannot + * compare removed, logging each one. Anything else is passed through + * untouched, including a shape that is not a manifest at all — deciding + * that is the schema's job, not this function's. + */ +function withComparableFloorsOnly(value: unknown): unknown { + if (!isRecord(value) || !isRecord(value.package)) return value + + const kept: Record = {} + let droppedAny = false + for (const [field, fieldValue] of Object.entries(value.package)) { + if (FLOOR_FIELDS.includes(field) && !isUsableFloor(fieldValue)) { + console.warn( + `[package-manifest] installed package declares an unreadable ${field} (${JSON.stringify(fieldValue)}); ` + + `ignoring it — the compatibility floor it intends cannot be enforced`, + ) + droppedAny = true + continue + } + kept[field] = fieldValue + } + + return droppedAny ? { ...value, package: kept } : value +} + +/** + * Validate a manifest read back from a package that is ALREADY + * INSTALLED, dropping a floor this codebase cannot compare rather than + * rejecting the whole document. + * + * Strict where the artefact enters, tolerant where we are only reading + * what is already on disk. `importFromFile` refuses an unreadable floor + * — that is the boundary, and refusing there is what makes the promise + * in `docs/package-format.md` true. But a package installed BEFORE that + * boundary existed can carry such a floor, and rejecting its manifest on + * load would make the boards it provides disappear from the board lookup + * with no message, on an upgrade where the user did nothing. + * + * Dropping the field leaves the package exactly as unconstrained as it + * already was — an unreadable floor never gated anything (see + * `isVersionAtLeast`) — while the log keeps the cause visible instead of + * silently trading one invisible outcome for another. + */ +export function parseInstalledPackageManifest(value: unknown): PackageManifest | null { + return parsePackageManifest(withComparableFloorsOnly(value)) +} From 291adedf95305a8854fd93f2324613befe51f384 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 7 Aug 2026 14:45:46 -0400 Subject: [PATCH 67/79] fix(compile): install vendor cores from the VPP's board manager URL A VPP that declares `target.boardManagerUrl` could not be compiled when its core lives outside arduino-cli's built-in index. Selecting an IndustrialShields board failed with: Invalid argument passed: Platform 'industrialshields:esp32' not found Arduino CLI process exited with code 7 The URL was resolved onto `BoardBuildInfo.boardManagerUrl` and then dropped: `resolveBoardSelection` did not copy it onto `boardEntry`, `InstallArduinoCoreArgs` had no field to carry it, and `handleCoreInstallation` spawned `core install` without `--additional-urls`. Vendor indexes shipped in VPPs were dead data. It went unnoticed because every other VPP targets a core that is either built into arduino-cli (arduino:avr, arduino:samd, arduino:mbed_edge) or hardcoded in the editor's `ARDUINO_DATA` (esp32, STM32, rp2040, FACTS). IndustrialShields is the first VPP core in neither list. Changes: - `InstallArduinoCoreArgs` gains `boardManagerUrl`, forwarded from `boardEntry` by the pipeline and populated by `resolveBoardSelection`. - `handleCoreInstallation` accepts the URL and passes `--additional-urls` to `core install`. - `handleCoreUpdateIndex` takes the URL too and is now actually called - it was dead code. `core install --additional-urls` alone is not enough, because the CLI resolves the platform against its cached index. The refresh is best-effort: a network failure there must not mask the real install error. - The arduino-cli config was written with `{ flag: 'wx' }` and skipped on EEXIST, so any URL added to `ARDUINO_DATA` never reached an existing install. Missing URLs are now merged into the existing file, preserving user-added entries and other settings. Vendors now ship their board index in the VPP instead of needing a patch to the editor's hardcoded list. Co-Authored-By: Claude Opus 5 (1M context) --- .../handle-core-installation.test.ts | 69 +++++++++++++++++++ .../editor/compiler/compiler-module.ts | 64 +++++++++++++++-- .../compiler/editor-compiler-platform-port.ts | 7 ++ .../editor/services/user-service/index.ts | 54 ++++++++++++--- .../shared/compile/__tests__/pipeline.test.ts | 36 ++++++++++ .../__tests__/resolve-board-selection.test.ts | 55 +++++++++++++++ src/backend/shared/compile/pipeline.ts | 11 +++ .../compile/steps/resolve-board-selection.ts | 5 ++ .../shared/ports/compiler-platform-port.ts | 10 +++ 9 files changed, 298 insertions(+), 13 deletions(-) diff --git a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts index 393bd63d6..36415b989 100644 --- a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts @@ -126,3 +126,72 @@ describe('handleCoreInstallation (prebuilt core pin = exact manifest version)', expect(message).toMatch(/already installed/) }) }) + +/** + * Vendor board-manager index support. + * + * Regression cover for "Platform 'industrialshields:esp32' not found": a VPP + * declaring `target.boardManagerUrl` must reach arduino-cli as + * `--additional-urls`, on BOTH `core update-index` and `core install`. The + * index refresh is what makes the install resolvable — `core install` alone + * matches against the cached index and still fails. + */ +describe('handleCoreInstallation (vendor board manager URL)', () => { + const VENDOR_URL = 'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json' + let compilerModule: CompilerModule + + beforeEach(() => { + compilerModule = new CompilerModule() + jest.mocked(spawn).mockReset() + jest.mocked(spawn).mockImplementation(() => fakeChild(0) as unknown as ReturnType) + jest.spyOn(compilerModule, 'getArduinoInstalledCores').mockResolvedValue({} as InstalledCores) + }) + + it('refreshes the index against the vendor URL BEFORE installing', async () => { + await compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), undefined, VENDOR_URL) + + expect(spawn).toHaveBeenCalledTimes(2) + const [, updateArgv] = jest.mocked(spawn).mock.calls[0] + const [, installArgv] = jest.mocked(spawn).mock.calls[1] + expect(updateArgv).toEqual(expect.arrayContaining(['core', 'update-index', '--additional-urls', VENDOR_URL])) + expect(installArgv).toEqual( + expect.arrayContaining(['core', 'install', 'industrialshields:esp32', '--additional-urls', VENDOR_URL]), + ) + }) + + it('passes --additional-urls alongside a pinned core version', async () => { + await compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), '2.7.1', VENDOR_URL) + + const [, installArgv] = jest.mocked(spawn).mock.calls[1] + expect(installArgv).toEqual( + expect.arrayContaining(['core', 'install', 'industrialshields:esp32@2.7.1', '--additional-urls', VENDOR_URL]), + ) + }) + + it('omits --additional-urls entirely when the board declares no vendor index', async () => { + await compilerModule.handleCoreInstallation('arduino:avr', jest.fn(), '1.8.6') + + expect(spawn).toHaveBeenCalledTimes(1) + const [, argv] = jest.mocked(spawn).mock.calls[0] + expect(argv).not.toContain('--additional-urls') + expect(argv).toEqual(expect.arrayContaining(['core', 'install', 'arduino:avr@1.8.6'])) + }) + + it('still attempts the install when the index refresh fails', async () => { + // First spawn (update-index) fails, second (install) succeeds. A flaky + // network on the refresh must not mask the install's own error. + let call = 0 + jest.mocked(spawn).mockImplementation(() => { + call += 1 + return fakeChild(call === 1 ? 1 : 0) as unknown as ReturnType + }) + + await expect( + compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), undefined, VENDOR_URL), + ).resolves.not.toThrow() + + expect(spawn).toHaveBeenCalledTimes(2) + const [, installArgv] = jest.mocked(spawn).mock.calls[1] + expect(installArgv).toEqual(expect.arrayContaining(['core', 'install', 'industrialshields:esp32'])) + }) +}) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 8c2309eb3..d5e50514c 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -938,9 +938,18 @@ class CompilerModule { // and the Arduino sketch walks them dynamically for I/O binding. // The debugger will be redesigned in Phase 4. - // TODO: This method is used to update the index of the Arduino core. - // We should validate if this is necessary and if it works correctly. - async handleCoreUpdateIndex(handleOutputData: HandleOutputDataCallback) { + /** + * `arduino-cli core update-index` — refetch the platform indexes. + * + * Required before installing a core that lives in a third-party index: + * passing `--additional-urls` to `core install` alone is not enough, + * because the CLI resolves the platform against its *cached* index and + * reports "Platform not found" until that cache has seen the vendor URL. + * + * `additionalUrls` is forwarded so the refresh covers the vendor index + * as well as the ones configured in `arduino-cli.yaml`. + */ + async handleCoreUpdateIndex(handleOutputData: HandleOutputDataCallback, additionalUrls?: string) { return new Promise>((resolve, reject) => { let binaryPath = this.arduinoCliBinaryPath const [flag, configFilePath] = this.arduinoCliBaseParameters @@ -949,7 +958,13 @@ class CompilerModule { // INFO: On Windows, we need to add the .exe extension to the binary path. binaryPath += '.exe' } - const executeCommand = spawn(binaryPath, ['core', 'update-index', flag, configFilePath]) + const executeCommand = spawn(binaryPath, [ + 'core', + 'update-index', + ...(additionalUrls ? ['--additional-urls', additionalUrls] : []), + flag, + configFilePath, + ]) let stderrData = '' @@ -971,10 +986,24 @@ class CompilerModule { }) } + /** + * Install the Arduino core a board needs, pulling it from a vendor + * board-manager index when the board declares one. + * + * `boardManagerUrl` comes from the VPP manifest (`target.boardManagerUrl`) + * or hals.json (`board_manager_url`). Cores outside arduino-cli's + * built-in index — `industrialshields:esp32`, for example — are + * unresolvable without it, and the install dies with + * "Platform '' not found" (exit 7). When one is supplied we refresh + * the index against that URL first, then install with the same + * `--additional-urls`; both steps are needed, since `core install` + * resolves against the cached index. + */ async handleCoreInstallation( boardCore: string | null, handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, coreVersion?: string, + boardManagerUrl?: string, ) { if (boardCore === null) return @@ -994,6 +1023,25 @@ class CompilerModule { handleOutputData(`Installing pinned core ${coreRef} (required by a prebuilt library)...`, 'info') } + // Refresh the platform index against the vendor URL before installing. + // Non-fatal: a transient network failure here should not mask the far + // more useful error that `core install` produces a moment later. + if (boardManagerUrl) { + handleOutputData(`Using vendor board index: ${boardManagerUrl}`, 'info') + try { + // `handleCoreUpdateIndex` logs at the wider 'info' | 'warning' | + // 'error' level set; this callback only accepts 'info' | 'error', + // so fold 'warning' down to 'info'. + await this.handleCoreUpdateIndex( + (chunk, level) => handleOutputData(chunk, level === 'error' ? 'error' : 'info'), + boardManagerUrl, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + handleOutputData(`Warning: could not refresh the board index (${message}). Continuing.`, 'info') + } + } + let binaryPath = this.arduinoCliBinaryPath if (CompilerModule.HOST_PLATFORM === 'win32') { @@ -1001,7 +1049,13 @@ class CompilerModule { binaryPath += '.exe' } return new Promise>((resolve, reject) => { - const executeCommand = spawn(binaryPath, ['core', 'install', coreRef, ...this.arduinoCliBaseParameters]) + const executeCommand = spawn(binaryPath, [ + 'core', + 'install', + coreRef, + ...(boardManagerUrl ? ['--additional-urls', boardManagerUrl] : []), + ...this.arduinoCliBaseParameters, + ]) let stderrData = '' diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 2a5e88beb..c85622d40 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -209,6 +209,12 @@ export function createEditorCompilerPlatformPort( * `handleCoreInstallation` already takes a core id and a log * callback — direct passthrough modulo the log-shape * translation. + * + * `args.boardManagerUrl` (the VPP's `target.boardManagerUrl`) is + * forwarded so vendor cores outside arduino-cli's built-in index + * install automatically rather than failing with "Platform not + * found". `handleCoreInstallation` refreshes the index against + * that URL before installing. */ async installArduinoCore(args: InstallArduinoCoreArgs, log: PlatformLog): Promise { try { @@ -219,6 +225,7 @@ export function createEditorCompilerPlatformPort( log(message, level ?? 'info') }, args.coreVersion, + args.boardManagerUrl, ) return { ok: true } } catch (error) { diff --git a/src/backend/editor/services/user-service/index.ts b/src/backend/editor/services/user-service/index.ts index 5e6aae3c3..c4ee7ebef 100644 --- a/src/backend/editor/services/user-service/index.ts +++ b/src/backend/editor/services/user-service/index.ts @@ -1,7 +1,7 @@ import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { exec } from 'child_process' import { app } from 'electron' -import { access, constants, mkdir, rename, rm, writeFile } from 'fs/promises' +import { access, constants, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' import { basename, join } from 'path' import { promisify } from 'util' @@ -144,22 +144,60 @@ class UserService { } /** - * Checks if the Arduino CLI configuration file exists and creates it if it doesn't. + * Ensure the Arduino CLI configuration file exists and carries every + * board-manager URL the editor ships with. + * + * This used to write with `{ flag: 'wx' }` and swallow `EEXIST`, which + * made the file effectively write-once: any URL added to `ARDUINO_DATA` + * after a user's first launch never reached them, and the only fix was + * deleting the file by hand. Now missing URLs are merged into the + * existing config on every start. + * + * Merge, never overwrite: users add their own indexes and change other + * settings in this file, and clobbering it would silently discard them. + * Anything already present is left untouched, including ordering. */ async #checkIfArduinoCliConfigExists(): Promise { const pathToArduinoCliConfig = join(app.getPath('userData'), 'User', 'arduino-cli.yaml') try { await writeFile(pathToArduinoCliConfig, UserService.ARDUINO_FILE_CONTENT, { flag: 'wx' }) + return } catch (err) { - // If the error is due to the file already existing, log a warning and continue. - if (err instanceof Error && err.message.includes('EEXIST')) { - console.warn(`File already exists at ${pathToArduinoCliConfig}.\nSkipping creation.`) - } else if (err instanceof Error) { - console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) - } else { + if (!(err instanceof Error && err.message.includes('EEXIST'))) { console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + return } } + + // File already exists — reconcile its `additional_urls` with ours. + try { + const existing = await readFile(pathToArduinoCliConfig, 'utf-8') + const shipped = UserService.ARDUINO_FILE_CONTENT.match(/^\s*-\s*(https?:\/\/\S+)\s*$/gm) ?? [] + const missing = shipped + .map((line) => line.trim().replace(/^-\s*/, '')) + .filter((url) => !existing.includes(url)) + + if (missing.length === 0) return + + // Splice the missing entries in under the existing `additional_urls:` + // key, matching its indentation so the YAML stays valid. + const anchor = existing.match(/^(\s*)additional_urls:\s*$/m) + if (!anchor) { + console.warn( + `Arduino CLI config at ${pathToArduinoCliConfig} has no 'additional_urls' key. ` + + `Leaving it alone; missing board indexes: ${missing.join(', ')}`, + ) + return + } + const firstEntry = existing.match(/^(\s*)-\s*https?:\/\//m) + const indent = firstEntry ? firstEntry[1] : `${anchor[1]} ` + const updated = existing.replace(anchor[0], `${anchor[0]}\n${missing.map((u) => `${indent}- ${u}`).join('\n')}`) + + await writeFile(pathToArduinoCliConfig, updated, 'utf-8') + console.warn(`Added ${missing.length} missing board manager URL(s) to ${pathToArduinoCliConfig}.`) + } catch (err) { + console.error(`Error updating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + } } async #executeArduinoCliCommand(command: string): Promise<{ stderr: string; stdout: string }> { diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index 169789e12..54c668441 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -199,6 +199,42 @@ describe('runCompilePipeline — simulator path', () => { expect(callArgs.argv).toEqual(['compile', '-b', 'arduino:avr:mega']) }) + // Regression: the board's `boardManagerUrl` (VPP `target.boardManagerUrl`) + // was resolved onto boardEntry but never forwarded to installArduinoCore, + // so vendor cores outside arduino-cli's built-in index could not be + // installed — "Platform 'industrialshields:esp32' not found". + it('forwards boardEntry.boardManagerUrl to installArduinoCore', async () => { + const port = makePort() + const { emit } = captureEvents() + const boardManagerUrl = + 'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json' + await runCompilePipeline( + makeArgs({ + isSimulator: false, + boardRuntime: 'arduino-cli', + boardEntry: { + platform: 'industrialshields:esp32:esp32plc', + core: 'industrialshields:esp32', + boardManagerUrl, + }, + }), + port, + emit, + ) + expect(port.installArduinoCore).toHaveBeenCalledWith( + expect.objectContaining({ coreId: 'industrialshields:esp32', boardManagerUrl }), + expect.any(Function), + ) + }) + + it('omits boardManagerUrl for boards that do not declare one', async () => { + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline(makeArgs({ isSimulator: false, boardRuntime: 'arduino-cli' }), port, emit) + const [coreArgs] = port.installArduinoCore.mock.calls[0] + expect(coreArgs).not.toHaveProperty('boardManagerUrl') + }) + it('calls installArduinoCore + installArduinoLib before compileArduino (no-op semantics for web)', async () => { const port = makePort() const { emit } = captureEvents() diff --git a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts index 1998eb024..41b2ea973 100644 --- a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts +++ b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts @@ -239,4 +239,59 @@ describe('resolveBoardSelection', () => { expect(result.boardEntry.extra_libraries).toEqual(['P1AM']) } }) + + it('carries target.boardManagerUrl through to boardEntry', () => { + // A VPP whose core is not in arduino-cli's built-in index must surface + // its vendor index here, or the pipeline has nothing to hand to + // `installArduinoCore` and the install fails with "Platform not found". + const boardManagerUrl = + 'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json' + const pkg: InstalledPackage = { + packageId: 'com.openplc.industrialshields', + version: '1.0.1', + installedAt: '2026-01-01T00:00:00.000Z', + path: '/fake/packages/industrialshields', + devices: ['esp32-plc-14-0-10v'], + } + const manifest: PackageManifest = { + formatVersion: '1.0', + package: { + id: 'com.openplc.industrialshields', + name: 'IndustrialShields PLCs', + version: '1.0.1', + vendor: { name: 'Industrial Shields', logo: 'l.png' }, + description: 'd', + }, + devices: [ + { + id: 'esp32-plc-14-0-10v', + name: 'ESP32 PLC 14 0-10V', + preview: 'p.png', + target: { + type: 'arduino-cli', + core: 'industrialshields:esp32', + platform: 'industrialshields:esp32:plc14ios:cpu=plc14ios', + boardManagerUrl, + }, + hal: { + type: 'arduino-hal', + source: 'hal/arduino/esp32plc.cpp', + define: 'ISPLC_ESP32_PLC_14_0_10V', + }, + }, + ], + } as unknown as PackageManifest + const packageManager: PackageManagerPort = { + listInstalled: () => [pkg], + getInstalledPackageManifest: (id) => (id === pkg.packageId ? manifest : null), + } + const resolver = makeResolver({}, { packageManager }) + + const result = resolveBoardSelection(resolver, 'ESP32 PLC 14 0-10V') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardEntry.core).toBe('industrialshields:esp32') + expect(result.boardEntry.boardManagerUrl).toBe(boardManagerUrl) + } + }) }) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index 1944d15bb..9100be5ec 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -118,6 +118,11 @@ export interface BoardHalsBuildEntry extends BoardHalsCompileEntry { /** Exact Arduino core version to install/verify before linking a prebuilt * arduino library (ABI-locked). From the VPP manifest `target.coreVersion`. */ coreVersion?: string + /** Vendor board-manager index (`package__index.json`). From the + * VPP manifest `target.boardManagerUrl` or hals.json `board_manager_url`. + * Forwarded to `installArduinoCore`, which passes it to arduino-cli as + * `--additional-urls` so cores outside the built-in index resolve. */ + boardManagerUrl?: string /** Compiler / runtime identifier (`'arduino-cli' | 'openplc-compiler' * | 'simulator'`). Used by `resolveTargetCapabilities`'s * preset lookup — without this the resolver can't pick the right @@ -719,6 +724,12 @@ async function runCompilePipelineInner( coreId: typeof boardEntry.platform === 'string' ? deriveArduinoCoreFromPlatform(boardEntry.platform) : '', // Pin the exact core version for prebuilt arduino libraries (ABI-locked). ...(boardEntry.coreVersion ? { coreVersion: boardEntry.coreVersion } : {}), + // Vendor board-manager index for cores outside arduino-cli's built-in + // list. Resolved from the VPP manifest's `target.boardManagerUrl`; the + // editor turns it into `--additional-urls` (and refreshes the index) + // so the core can be auto-installed instead of erroring out with + // "Platform not found". + ...(boardEntry.boardManagerUrl ? { boardManagerUrl: boardEntry.boardManagerUrl } : {}), }, makePlatformLog(emit, 'core-install'), ) diff --git a/src/backend/shared/compile/steps/resolve-board-selection.ts b/src/backend/shared/compile/steps/resolve-board-selection.ts index 492055486..11dc29c62 100644 --- a/src/backend/shared/compile/steps/resolve-board-selection.ts +++ b/src/backend/shared/compile/steps/resolve-board-selection.ts @@ -68,6 +68,11 @@ export function resolveBoardSelection(resolver: BoardInfoResolver, boardTarget: // come from the VPP manifest via BoardBuildInfo; absent for source boards. ...(boardInfo.precompiledLibraryDir ? { precompiledLibraryDir: boardInfo.precompiledLibraryDir } : {}), ...(boardInfo.coreVersion ? { coreVersion: boardInfo.coreVersion } : {}), + // Vendor board-manager index, so a core outside arduino-cli's built-in + // list can be auto-installed. The resolver fills this from the VPP + // manifest's `target.boardManagerUrl` (or hals.json `board_manager_url`); + // dropping it here is what made every VPP-declared index dead data. + ...(boardInfo.boardManagerUrl ? { boardManagerUrl: boardInfo.boardManagerUrl } : {}), // Capability resolution inputs. `resolveTargetCapabilities` // reads `compiler` + `vpp` + `capabilities` on whatever board // shape it's handed — without forwarding all three the diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index cc13ae2fa..349caa6e0 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -197,6 +197,16 @@ export interface InstallArduinoCoreArgs { * fails if it is unavailable — required for prebuilt arduino-hal boards * whose precompiled `.a` is ABI-locked to that core version. */ coreVersion?: string + /** Optional third-party board-manager index URL (e.g. a vendor's + * `package__index.json`). Sourced from the VPP manifest's + * `target.boardManagerUrl` (or `board_manager_url` in hals.json) and + * forwarded to arduino-cli as `--additional-urls`. + * + * Cores outside arduino-cli's built-in index are invisible without it: + * `core install industrialshields:esp32` fails with "Platform not found" + * unless the vendor index is supplied AND `core update-index` has been + * run against it. The editor does both; web ignores this field. */ + boardManagerUrl?: string } /** Arduino-CLI library install (editor-only. Same no-op From 5ceec8ea24aba49df0a6b5aec9864b76130e18bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 7 Aug 2026 21:05:04 +0200 Subject: [PATCH 68/79] fix(RTOP-193): find license_blob.h in either repo layout The parity test hardcoded the editor's `resources/sources/Baremetal` path, so on openplc-web it died at import with ENOENT and took the whole suite file with it. The sync gate MAPS that directory onto `src/assets/firmware/Baremetal` rather than mirroring the path (MAPPED_SURFACES in compare-surfaces.py), and the test never knew. Unnoticed until now because neither repo runs a test job in CI. Resolves whichever layout the checkout has, and throws when neither exists rather than skipping: a parity test that vanishes when it cannot find its header is worse than no test at all, because the suite goes green while the struct layout it is supposed to pin drifts unwatched. Verified by renaming the header -- it fails loudly. Co-Authored-By: Claude Opus 5 --- .../__tests__/license-blob-c-parity.test.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts b/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts index 7b250e6d6..f03a51975 100644 --- a/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts +++ b/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts @@ -21,12 +21,32 @@ * no C test harness to hang one on today. */ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { crc32IsoHdlc, LIC_BLOB_SIZE, LIC_MAGIC_LE, LIC_PAYLOAD_SIZE } from '../license-blob' -const HEADER_PATH = join(__dirname, '..', '..', '..', '..', '..', 'resources', 'sources', 'Baremetal', 'license_blob.h') +/** + * This test file is byte-identical across openplc-editor and openplc-web, but the + * firmware sources it reads are NOT in the same place: the sync gate maps the + * editor's `resources/sources/Baremetal` onto the web's `src/assets/firmware/ + * Baremetal` (MAPPED_SURFACES in compare-surfaces.py) rather than mirroring the + * path. So resolve whichever one this checkout has. + * + * Deliberately throws when neither exists instead of skipping. A parity test that + * quietly disappears when it cannot find the header is worse than no test: the + * suite goes green while the layout it is supposed to pin drifts unwatched. + */ +const HEADER_CANDIDATES = [ + join(__dirname, '..', '..', '..', '..', '..', 'resources', 'sources', 'Baremetal', 'license_blob.h'), + join(__dirname, '..', '..', '..', '..', 'assets', 'firmware', 'Baremetal', 'license_blob.h'), +] + +const HEADER_PATH = HEADER_CANDIDATES.find((candidate) => existsSync(candidate)) + +if (HEADER_PATH === undefined) { + throw new Error(`license_blob.h not found. Looked in:\n${HEADER_CANDIDATES.join('\n')}`) +} const header = readFileSync(HEADER_PATH, 'utf-8') From 72d128763e4ee5e9b6092cf49c9bdd48fee0869e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Fri, 7 Aug 2026 17:24:49 -0300 Subject: [PATCH 69/79] feat(data-types): wire the .dt code view into the ST language server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go-to-definition on a user type now lands in that type's own code view with the cursor on the declaration — a struct field target lands on its field line — instead of merely opening the form tab (DOPE-537). The buffer gets a real identity (`inmemory://dtview/.dt`) so the LSP can recognise it, and `resolveStLspContext` remaps it onto the aggregate datatypes document. Both frames open with a `TYPE` line, so a single shift derived from the type's span covers completion, hover, signature help, definition, references and formatting at once. The span map is computed from the serializer rather than tracked in the offset registry: there is no registration lifecycle to keep in sync and no window where a datatype edit and the stored offset disagree. Semantic tokens and diagnostics need more than a shift, because the model's text and the document the answers come from are two different strings that only agree while the buffer is committed: - The token window holds the entry's own lines and is rebased onto the view's frame via `outputStartLine`. Widening the window instead would drag in the previous entry's last line, whose columns overrun the 4-character `TYPE` line and make Monaco reject the whole batch. - While the buffer diverges from the store the window is empty. No colours beats colours describing the previous text. - A store change re-drives both, since the model's text is untouched by it and Monaco would otherwise never re-query. - The diagnostics mirror caches each publish together with the spans it was computed against, and replays it when a model mounts later. Replaying through freshly computed spans puts markers on the wrong line, or drops them, once the store has moved. The go-to-definition cursor is deliberately keyed on its own identity and not on the current display: including the display would re-fire the forced switch when the user toggles back to the table and pin the tab in code mode. Refs DOPE-537 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GvGrhB1LyJz9MBwYhjoBDY --- .../__tests__/code-view-toggle.test.tsx | 38 ++++++ .../_features/[workspace]/data-type/index.tsx | 28 ++++- .../variables-code-editor/index.tsx | 12 +- .../__tests__/semantic-tokens-shift.test.ts | 40 +++++++ .../internal/semantic-tokens-shift.ts | 10 +- .../services/lsp-shared/semantic-tokens.ts | 8 +- .../lsp-shared/start-language-service.ts | 13 ++ .../goto-definition-redirect.test.ts | 84 ++++++++++++- .../services/st-lsp/__tests__/types.test.ts | 28 ++++- .../st-lsp/goto-definition-redirect.ts | 56 ++++++--- src/frontend/services/st-lsp/index.ts | 113 ++++++++++++++++++ src/frontend/services/st-lsp/types.ts | 26 ++++ src/frontend/store/slices/editor/types.ts | 4 +- .../__tests__/data-type-serializer.test.ts | 54 ++++++++- .../utils/PLC/data-type-serializer.ts | 27 +++++ 15 files changed, 515 insertions(+), 26 deletions(-) create mode 100644 src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx create mode 100644 src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts diff --git a/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx b/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx new file mode 100644 index 000000000..f6851127c --- /dev/null +++ b/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +// The code view pulls in Monaco, which cannot run in jsdom. +vi.mock('@root/frontend/components/_organisms/variables-code-editor', () => ({ + VariablesCodeEditor: () =>

, +})) + +vi.mock('@root/frontend/utils/feature-flags', () => ({ + isDataTypeFilesEnabled: () => true, +})) + +import { useOpenPLCStore } from '@root/frontend/store' + +import { DataTypeEditor } from '../index' + +/** Reproduce what a go-to-definition redirect leaves behind. */ +function arriveFromGotoDefinition(name: string) { + const { editorActions } = useOpenPLCStore.getState() + editorActions.updateModelStructureForName(name, { display: 'code' }) + editorActions.setEditorCursor(name, { lineNumber: 2, column: 3, offset: 0, target: 'data-type' }) +} + +describe('DataTypeEditor code view toggle', () => { + it('lets the user switch back to the table after a goto-definition jump', () => { + const created = useOpenPLCStore.getState().datatypeActions.create({ name: 'Motor', derivation: 'structure' }) + expect(created.ok).toBe(true) + + arriveFromGotoDefinition('Motor') + render() + expect(screen.getByTestId('variables-code-editor')).toBeTruthy() + + fireEvent.click(screen.getByLabelText('Data type table visualization')) + + // The jump's cursor is still on the model; it must not re-assert code + // mode and pin the tab there. + expect(screen.queryByTestId('variables-code-editor')).toBeNull() + }) +}) diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index ae5a87ab9..2c6eabadf 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -1,9 +1,10 @@ -import { ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react' +import { ComponentPropsWithoutRef, useEffect, useMemo, useRef, useState } from 'react' import type { PLCDataType } from '../../../../../middleware/shared/ports/types' import { CodeIcon } from '../../../../assets/icons/interface/CodeIcon' import { TableIcon } from '../../../../assets/icons/interface/TableIcon' import { usePouSnapshot } from '../../../../hooks/use-pou-snapshot' +import { dtViewUri } from '../../../../services/st-lsp/types' import { useOpenPLCStore } from '../../../../store' import { extractSearchQuery } from '../../../../store/slices/search/utils' import { cn } from '../../../../utils/cn' @@ -152,6 +153,29 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { commitCodeRef.current = commitCode }) + // Stable reference, or the child's cursor-jump effect re-fires every + // keystroke and re-selects the navigated line. + const codeCursorPosition = useMemo( + () => + model?.cursorPosition?.target === 'data-type' + ? { + lineNumber: model.cursorPosition.lineNumber, + column: model.cursorPosition.column, + target: 'data-type' as const, + } + : undefined, + [model?.cursorPosition?.target, model?.cursorPosition?.lineNumber, model?.cursorPosition?.column], + ) + + // Goto-definition can land here while the tab is still in table mode. + // Keyed on the cursor alone: including `display` would re-fire on the + // user's own switch back to table and pin the tab in code mode. + useEffect(() => { + if (!codeCursorPosition || display === 'code') return + updateModelStructureForName(dataTypeName, { display: 'code', code: editorCode }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [codeCursorPosition]) + useEffect(() => { if (display !== 'code') return @@ -327,6 +351,8 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { code={editorCode} onCodeChange={setEditorCode} shouldUseDarkMode={shouldUseDarkMode} + modelUri={dtViewUri(dataTypeName)} + cursorPosition={codeCursorPosition} />
{parseError &&

Error: {parseError}

} diff --git a/src/frontend/components/_organisms/variables-code-editor/index.tsx b/src/frontend/components/_organisms/variables-code-editor/index.tsx index 944cea274..c2d1daed6 100644 --- a/src/frontend/components/_organisms/variables-code-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-code-editor/index.tsx @@ -41,6 +41,12 @@ interface VariablesCodeEditorProps { * cross-routing. */ pouName?: string + /** + * Explicit Monaco model URI, for surfaces that route to the LSP but + * aren't POU variables (the data type `.dt` code view). Takes + * precedence over the URI derived from `pouName`. + */ + modelUri?: string /** * Programmatic cursor jump (e.g. compile-error click → vars-text * view, or Go to Definition redirect for a variable declaration). @@ -54,7 +60,7 @@ interface VariablesCodeEditorProps { * (it's meant for the body editor). Undefined or * `target === 'variables'` is honoured here. */ - cursorPosition?: { lineNumber: number; column: number; target?: 'body' | 'variables' } + cursorPosition?: { lineNumber: number; column: number; target?: 'body' | 'variables' | 'data-type' } } const VariablesCodeEditor = ({ @@ -63,9 +69,11 @@ const VariablesCodeEditor = ({ shouldUseDarkMode, cursorPosition, pouName, + modelUri, language = 'st', readOnly = false, }: VariablesCodeEditorProps) => { + const resolvedModelUri = modelUri ?? (pouName ? pouVarsUri(pouName) : undefined) const editorRef = useRef(null) const containerRef = useRef(null) const [editorMounted, setEditorMounted] = useState(false) @@ -146,7 +154,7 @@ const VariablesCodeEditor = ({ height='100%' width='100%' language={language} - {...(pouName ? { path: pouVarsUri(pouName) } : {})} + {...(resolvedModelUri ? { path: resolvedModelUri } : {})} defaultValue={''} value={code} onMount={handleEditorDidMount} diff --git a/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts b/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts new file mode 100644 index 000000000..447baa6bc --- /dev/null +++ b/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts @@ -0,0 +1,40 @@ +/** + * @jest-environment jsdom + */ +import { shiftSemanticTokensToBody } from '../internal/semantic-tokens-shift' + +// One token per line of a synthesised datatypes document: +// 0 `TYPE` col 0 +// 1 ` Colors : (RED);` col 2 +// 2 ` Motor : STRUCT` col 2 +// 3 ` speed : INT;` col 4 +// 4 ` END_STRUCT;` col 2 +const AGGREGATE = [0, 0, 4, 0, 0, 1, 2, 6, 0, 0, 1, 2, 5, 0, 0, 1, 4, 5, 0, 0, 1, 2, 10, 0, 0] + +describe('shiftSemanticTokensToBody', () => { + it('drops tokens before the window and rebases the rest to line 0 by default', () => { + expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 1, 2))).toEqual([0, 2, 6, 0, 0]) + }) + + it('rebases onto outputStartLine so a view can render its own frame above the window', () => { + // `Motor` occupies lines 2..4; its `.dt` view renders them under a + // local `TYPE` line, so they land on local lines 1..3. + expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 2, 5, 1))).toEqual([ + 1, 2, 5, 0, 0, 1, 4, 5, 0, 0, 1, 2, 10, 0, 0, + ]) + }) + + it('never emits the line above the window, whose columns can overrun a short frame line', () => { + const out = Array.from(shiftSemanticTokensToBody(AGGREGATE, 2, 5, 1)) + // The `Colors` token (length 6) would not fit on the 4-character `TYPE` line. + expect(out).not.toContain(6) + }) + + it('keeps everything from startLine when no end is given', () => { + expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 3))).toEqual([0, 4, 5, 0, 0, 1, 2, 10, 0, 0]) + }) + + it('returns an empty stream when the window selects nothing', () => { + expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 9, 12))).toEqual([]) + }) +}) diff --git a/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts b/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts index da257c4ca..2b4e042e3 100644 --- a/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts +++ b/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts @@ -7,10 +7,15 @@ * 1. Decode deltas to absolute (line, col) positions. * 2. Keep tokens whose line is in `[startLine, endLineExclusive)`. * Both ends are LSP coordinates. - * 3. Subtract `startLine` from each surviving token's line so the + * 3. Rebase each surviving token onto `outputStartLine` so the * output is Monaco-relative. * 4. Re-encode as a delta stream Monaco can consume directly. * + * `outputStartLine` exists so a view that renders its own framing + * above the window doesn't have to widen the window to compensate: + * doing that drags in the preceding line's tokens, whose columns can + * overrun the shorter frame line ("end character > line length"). + * * Used in two modes by the ST LSP today: * - **Body view**: `startLine = bodyLineOffset` (preamble line count), * `endLineExclusive = ∞` — drop the preamble, keep everything @@ -29,6 +34,7 @@ export function shiftSemanticTokensToBody( data: number[], startLine: number, endLineExclusive: number = Number.POSITIVE_INFINITY, + outputStartLine: number = 0, ): Uint32Array { // Decode to absolute positions. const abs: Array<{ line: number; col: number; len: number; type: number; mods: number }> = [] @@ -51,7 +57,7 @@ export function shiftSemanticTokensToBody( for (const t of abs) { if (t.line < startLine) continue if (t.line >= endLineExclusive) continue - const shiftedLine = t.line - startLine + const shiftedLine = t.line - startLine + outputStartLine const dLine = shiftedLine - prevLine const dStart = dLine === 0 ? t.col - prevCol : t.col out.push(dLine, dStart, t.len, t.type, t.mods) diff --git a/src/frontend/services/lsp-shared/semantic-tokens.ts b/src/frontend/services/lsp-shared/semantic-tokens.ts index 987d13452..9690ff17a 100644 --- a/src/frontend/services/lsp-shared/semantic-tokens.ts +++ b/src/frontend/services/lsp-shared/semantic-tokens.ts @@ -39,12 +39,14 @@ export interface SemanticTokensRegistration extends monaco.IDisposable { * default keeps everything from `[lineOffset, +∞)` — drop the * preamble, keep the rest. ST's variables-text view overrides this * to clip the end at the body line so only VAR-block tokens render. + * `outputStartLine` rebases the kept window for views that render + * their own framing above it (the data type `.dt` code view). */ export type ResolveSemanticTokensViewport = ( lspUri: string, modelUri: string, lineOffset: number, -) => { startLine: number; endLineExclusive: number } +) => { startLine: number; endLineExclusive: number; outputStartLine?: number } const defaultViewport: ResolveSemanticTokensViewport = (_lspUri, _modelUri, lineOffset) => ({ startLine: lineOffset, @@ -87,10 +89,10 @@ export function registerLspSemanticTokens(opts: RegisterLspSemanticTokensOptions textDocument: { uri: lspUri }, }) if (!result) return null - const { startLine, endLineExclusive } = resolveViewport(lspUri, modelUri, lineOffset) + const { startLine, endLineExclusive, outputStartLine } = resolveViewport(lspUri, modelUri, lineOffset) return { ...(result.resultId ? { resultId: result.resultId } : {}), - data: shiftSemanticTokensToBody(result.data, startLine, endLineExclusive), + data: shiftSemanticTokensToBody(result.data, startLine, endLineExclusive, outputStartLine), } }, releaseDocumentSemanticTokens() { diff --git a/src/frontend/services/lsp-shared/start-language-service.ts b/src/frontend/services/lsp-shared/start-language-service.ts index 66af71bf7..5d7a5ecd7 100644 --- a/src/frontend/services/lsp-shared/start-language-service.ts +++ b/src/frontend/services/lsp-shared/start-language-service.ts @@ -84,6 +84,14 @@ export interface LanguageService { changeDocument(uri: string, content: string, version?: number): void /** Send `textDocument/didClose`. */ closeDocument(uri: string): void + /** + * Ask Monaco to re-query semantic tokens for every model in this + * language. Needed when a model's tokens derive from a *different* + * document (the datatype `.dt` view reads the aggregate doc), where + * a change to that document leaves the model's text untouched and + * therefore triggers no re-query of its own. + */ + refreshSemanticTokens(): void /** Tear down providers + transport. */ dispose(): void } @@ -232,6 +240,7 @@ export function startLanguageService(opts: StartLanguageServiceOptions): Languag openDocument: () => undefined, changeDocument: () => undefined, closeDocument: () => undefined, + refreshSemanticTokens: () => undefined, dispose: () => undefined, } } @@ -333,6 +342,10 @@ export function startLanguageService(opts: StartLanguageServiceOptions): Languag return { ready, + refreshSemanticTokens() { + semanticTokensRegistration?.refresh() + }, + openDocument(uri, content) { if (disposed) return const existing = documents.get(uri) diff --git a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts index 4d06cbd12..0dd2b0aa5 100644 --- a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts +++ b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts @@ -1,8 +1,9 @@ /** * @jest-environment jsdom */ -import type { PLCPou } from '../../../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou } from '../../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../../store' +import * as featureFlags from '../../../utils/feature-flags' import { setBodyLineOffset } from '../../lsp-shared/body-offsets' import { redirectDefinitionToStore } from '../goto-definition-redirect' @@ -62,6 +63,87 @@ describe('redirectDefinitionToStore', () => { ).toBe(false) }) + describe('datatypes URI with data types present', () => { + // Aggregate doc: line 0 `TYPE`, 1 `Colors : (...)`, then Motor's + // STRUCT spans 2..4 (declaration, one field, END_STRUCT). + const dataTypes: PLCDataType[] = [ + { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' }, + { + name: 'Motor', + derivation: 'structure', + variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }], + }, + ] + + beforeEach(() => { + setProjectPous([]) + openPLCStoreBase.setState((s) => ({ + ...s, + project: { ...s.project, data: { ...s.project.data, dataTypes } }, + })) + jest.spyOn(featureFlags, 'isDataTypeFilesEnabled').mockReturnValue(true) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('opens the owning type in code mode with the cursor on its declaration line', () => { + expect( + redirectDefinitionToStore({ + uri: 'inmemory://datatypes/__project__.st', + range: { start: { line: 1, character: 2 }, end: { line: 1, character: 8 } }, + }), + ).toBe(true) + + const state = openPLCStoreBase.getState() + expect(state.selectedTab).toBe('Colors') + // The active editor holds the fresh model — `updateModelStructureForName` + // writes there when the name matches, leaving `editors[]` behind. + const model = state.editor + expect(model.type === 'plc-datatype' && model.structure.display).toBe('code') + // Entry line 0 sits below the view's own `TYPE` frame → Monaco line 2. + expect(model.cursorPosition).toEqual({ lineNumber: 2, column: 3, offset: 0, target: 'data-type' }) + }) + + it('lands on a struct field line inside the owning type', () => { + // Aggregate line 3 = Motor's `speed` field (entry starts at 2). + expect( + redirectDefinitionToStore({ + uri: 'inmemory://datatypes/__project__.st', + range: { start: { line: 3, character: 4 }, end: { line: 3, character: 9 } }, + }), + ).toBe(true) + + const state = openPLCStoreBase.getState() + expect(state.selectedTab).toBe('Motor') + expect(state.editor.cursorPosition?.lineNumber).toBe(3) + }) + + it('returns false for the END_TYPE framing line past the last entry', () => { + expect( + redirectDefinitionToStore({ + uri: 'inmemory://datatypes/__project__.st', + range: { start: { line: 5, character: 0 }, end: { line: 5, character: 0 } }, + }), + ).toBe(false) + }) + + it('opens the form tab without a cursor when the code view is not built in', () => { + jest.spyOn(featureFlags, 'isDataTypeFilesEnabled').mockReturnValue(false) + expect( + redirectDefinitionToStore({ + uri: 'inmemory://datatypes/__project__.st', + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } }, + }), + ).toBe(true) + + const model = openPLCStoreBase.getState().editor + expect(model.type === 'plc-datatype' && model.structure.display).toBe('table') + expect(model.cursorPosition).toBeUndefined() + }) + }) + it('returns false when the target POU does not exist in the project', () => { setProjectPous([]) expect( diff --git a/src/frontend/services/st-lsp/__tests__/types.test.ts b/src/frontend/services/st-lsp/__tests__/types.test.ts index 63ad17e47..65df02511 100644 --- a/src/frontend/services/st-lsp/__tests__/types.test.ts +++ b/src/frontend/services/st-lsp/__tests__/types.test.ts @@ -1,4 +1,13 @@ -import { parsePouUri, parsePouVarsUri, POU_DECLARATION_LINE_COUNT, pouUri, pouVarsUri, stubUri } from '../types' +import { + dtViewUri, + parseDtViewUri, + parsePouUri, + parsePouVarsUri, + POU_DECLARATION_LINE_COUNT, + pouUri, + pouVarsUri, + stubUri, +} from '../types' describe('pouUri / stubUri', () => { it('produces well-formed in-memory URIs', () => { @@ -71,3 +80,20 @@ describe('POU_DECLARATION_LINE_COUNT', () => { expect(POU_DECLARATION_LINE_COUNT).toBe(1) }) }) + +describe('dtViewUri / parseDtViewUri', () => { + it('round-trips a data type name, encoding included', () => { + expect(dtViewUri('Motor')).toBe('inmemory://dtview/Motor.dt') + expect(parseDtViewUri(dtViewUri('My Type'))).toBe('My Type') + }) + + it('returns null for the other ST URI shapes', () => { + expect(parseDtViewUri(pouUri('Motor'))).toBeNull() + expect(parseDtViewUri(pouVarsUri('Motor'))).toBeNull() + expect(parseDtViewUri('inmemory://datatypes/__project__.st')).toBeNull() + }) + + it('does not collide with the pouvars parser', () => { + expect(parsePouVarsUri(dtViewUri('Motor'))).toBeNull() + }) +}) diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts index 7697f2e9e..d778456df 100644 --- a/src/frontend/services/st-lsp/goto-definition-redirect.ts +++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts @@ -39,10 +39,17 @@ import type { PLCDataType } from '../../../middleware/shared/ports/types' import { sanitizeAxisName, softMotionAxisNames } from '../../../middleware/shared/utils/ethercat' import { openPLCStoreBase } from '../../store' import { CreateEditorObjectFromTab } from '../../store/slices/tabs/utils' -import { serializeDataTypesToLines } from '../../utils/PLC/data-type-serializer' +import { isDataTypeFilesEnabled } from '../../utils/feature-flags' +import { dataTypeLineSpans } from '../../utils/PLC/data-type-serializer' import { getBodyLineOffset } from '../lsp-shared/body-offsets' import { normaliseLocation, routeToPou, routeToPouBody, routeToPouPreamble } from '../lsp-shared/definition-redirect' -import { DATA_TYPES_URI, parsePouUri, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_URI } from './types' +import { + DATA_TYPES_URI, + DT_VIEW_FRAME_LINE_COUNT, + parsePouUri, + RESOURCE_GLOBALS_URI, + SOFTMOTION_GLOBALS_URI, +} from './types' /** * Map an LSP line in the synthesised datatypes document to the @@ -56,18 +63,18 @@ import { DATA_TYPES_URI, parsePouUri, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_U * line counts that drift the moment a new field separator or * derivation lands on disk. */ -function findDataTypeAtLine(lspLine: number, dataTypes: PLCDataType[]): PLCDataType | null { +function findDataTypeAtLine( + lspLine: number, + dataTypes: PLCDataType[], +): { dataType: PLCDataType; lineInEntry: number } | null { // Synthesised doc: line 0 is `TYPE`, entries start at line 1. if (lspLine < 1) return null - const entries = serializeDataTypesToLines(dataTypes) const byName = new Map(dataTypes.map((dt) => [dt.name, dt])) - let cursor = 1 - for (const entry of entries) { - const span = entry.lines.length - if (lspLine >= cursor && lspLine < cursor + span) { - return byName.get(entry.name) ?? null + for (const [name, span] of dataTypeLineSpans(dataTypes)) { + if (lspLine >= span.start && lspLine < span.start + span.length) { + const dataType = byName.get(name) + return dataType ? { dataType, lineInEntry: lspLine - span.start } : null } - cursor += span } return null } @@ -103,6 +110,28 @@ function openDataTypeEditor(dataType: PLCDataType): boolean { return true } +/** + * Open the type's tab in code mode with the cursor on `lineInEntry` + * (0 = its declaration line). Falls back to the form tab when the + * `.dt` code view isn't built into this release. + */ +function routeToDataTypeCodeView(dataType: PLCDataType, lineInEntry: number, characterLsp: number): boolean { + if (!openDataTypeEditor(dataType)) return false + if (!isDataTypeFilesEnabled()) return true + const { + editorActions: { setEditorCursor, updateModelStructureForName }, + } = openPLCStoreBase.getState() + updateModelStructureForName(dataType.name, { display: 'code' }) + setEditorCursor(dataType.name, { + // The view renders its own `TYPE` frame line before the entry. + lineNumber: lineInEntry + DT_VIEW_FRAME_LINE_COUNT + 1, + column: Math.max(1, characterLsp + 1), + offset: 0, + target: 'data-type', + }) + return true +} + /** * Open the EtherCAT device (drive) editor for `deviceId` on `busName`, mirroring * the project-tree click path. Used to redirect go-to-definition on a SoftMotion @@ -206,10 +235,9 @@ export function redirectDefinitionToStore(loc: Location | LocationLink): boolean // branch the redirect would dead-end silently. if (target.uri === DATA_TYPES_URI) { const dataTypes = openPLCStoreBase.getState().project.data.dataTypes - const dt = findDataTypeAtLine(target.lineLsp, dataTypes) - if (!dt) return false - openDataTypeEditor(dt) - return true + const hit = findDataTypeAtLine(target.lineLsp, dataTypes) + if (!hit) return false + return routeToDataTypeCodeView(hit.dataType, hit.lineInEntry, target.characterLsp) } const parsed = parsePouUri(target.uri) diff --git a/src/frontend/services/st-lsp/index.ts b/src/frontend/services/st-lsp/index.ts index dd74eb89f..d8a4e2bb3 100644 --- a/src/frontend/services/st-lsp/index.ts +++ b/src/frontend/services/st-lsp/index.ts @@ -25,6 +25,7 @@ * disposed only at shutdown. */ +import type * as monaco from 'monaco-editor' import { type CompletionItem as LspCompletionItem, type CompletionList, @@ -35,6 +36,7 @@ import { } from 'vscode-languageserver-protocol' import { openPLCStoreBase } from '../../store' +import { dataTypeLineSpans, serializeDataTypeToText } from '../../utils/PLC/data-type-serializer' import { serializePouScopeForQuery } from '../../utils/PLC/pou-signature-serializer' import { getBodyLineOffset, @@ -49,6 +51,10 @@ import { redirectDefinitionToStore } from './goto-definition-redirect' import { redirectToGraphicalPou } from './graphical-redirect' import { registerScopedQueryApi, type ScopedCompletionItem } from './scoped-query' import { + DATA_TYPES_URI, + DT_VIEW_FRAME_LINE_COUNT, + dtViewUri, + parseDtViewUri, parsePouUri, parsePouVarsUri, POU_DECLARATION_LINE_COUNT, @@ -85,6 +91,9 @@ interface LoadStlibBufferParams { * that's `pou://`; for graphical/hybrid POUs it's `stub://`. * Either way the declaration is a single line at LSP index 0, * so the offset is a constant 1. + * - `dtview://.dt` (per-type code view): remap to the + * aggregate datatypes document. Both frames open with a `TYPE` + * line, so the shift is the type's span start minus that frame. * - Anything else: pass through unchanged. */ function resolveStLspContext(modelUri: string): LspContext { @@ -95,9 +104,64 @@ function resolveStLspContext(modelUri: string): LspContext { const lspUri = isStLanguage ? pouUri(varsPou) : stubUri(varsPou) return { lspUri, lineOffset: POU_DECLARATION_LINE_COUNT } } + const dtName = parseDtViewUri(modelUri) + if (dtName !== null) { + const span = dataTypeLineSpans(openPLCStoreBase.getState().project.data.dataTypes).get(dtName) + // An unknown name (type deleted while its tab is open) keeps the + // frame shift so requests stay inside the document. + return { lspUri: DATA_TYPES_URI, lineOffset: (span?.start ?? 1) - DT_VIEW_FRAME_LINE_COUNT } + } return { lspUri: modelUri, lineOffset: getBodyLineOffset(modelUri) } } +/** + * Aggregate-document line window backing a `.dt` code view, or null. + * Holds the entry's own lines only — widening it to cover the view's + * `TYPE` frame would pull in the previous entry's last line, whose + * columns overrun that 4-character frame line. + */ +function dtViewWindow(modelUri: string): { start: number; endExclusive: number } | null { + const dtName = parseDtViewUri(modelUri) + if (dtName === null) return null + const span = dataTypeLineSpans(openPLCStoreBase.getState().project.data.dataTypes).get(dtName) + if (!span) return null + return { start: span.start, endExclusive: span.start + span.length } +} + +/** + * True while a `.dt` model's text still matches what the store would + * serialise for that type. An uncommitted edit breaks the match, and + * tokens resolved against the aggregate document would then be painted + * onto text they don't describe — wrong colours, and columns past the + * end of shorter lines. + */ +function dtViewMatchesStore(dtName: string, monacoApi: typeof monaco): boolean { + const dataType = openPLCStoreBase.getState().project.data.dataTypes.find((d) => d.name === dtName) + if (!dataType) return false + const model = monacoApi.editor.getModels().find((m) => m.uri.toString() === dtViewUri(dtName)) + if (!model) return false + return model.getValue() === serializeDataTypeToText(dataType) +} + +let lastDataTypeDiagnostics: Diagnostic[] = [] + +/** Fan the aggregate doc's diagnostics out to every mounted `.dt` model. */ +function applyDataTypeDiagnostics(monacoApi: typeof monaco, markerOwner: string, defaultSource: string): void { + for (const [name, span] of dataTypeLineSpans(openPLCStoreBase.getState().project.data.dataTypes)) { + const model = monacoApi.editor.getModels().find((m) => m.uri.toString() === dtViewUri(name)) + if (!model) continue + const shift = span.start - DT_VIEW_FRAME_LINE_COUNT + const owned = lastDataTypeDiagnostics.filter( + (d) => d.range.start.line >= span.start && d.range.start.line < span.start + span.length, + ) + monacoApi.editor.setModelMarkers( + model, + markerOwner, + owned.map((d) => lspDiagnosticToMonaco(d, monacoApi, shift, defaultSource)), + ) + } +} + export function startStLsp(opts: StLspStartOptions): StLspService { const { stlibSource, monaco: monacoApi, workerUrlOverride, onCrash } = opts @@ -151,6 +215,20 @@ export function startStLsp(opts: StLspStartOptions): StLspService { // VAR-block region; body editors keep everything from the // body line onwards. resolveSemanticTokensViewport: (lspUri, modelUri, lineOffset) => { + const dtName = parseDtViewUri(modelUri) + if (dtName !== null) { + const dtWindow = dtViewWindow(modelUri) + // Empty window while the buffer is uncommitted: no colours beats + // colours describing the previous text. + if (!dtWindow || !monacoApi || !dtViewMatchesStore(dtName, monacoApi)) { + return { startLine: 0, endLineExclusive: 0 } + } + return { + startLine: dtWindow.start, + endLineExclusive: dtWindow.endExclusive, + outputStartLine: DT_VIEW_FRAME_LINE_COUNT, + } + } const isVarsView = parsePouVarsUri(modelUri) !== null return { startLine: lineOffset, @@ -162,6 +240,16 @@ export function startStLsp(opts: StLspStartOptions): StLspService { markerOwner: MARKER_OWNER, diagnosticSource: DIAGNOSTIC_SOURCE, diagnosticsMirror: (params, ctx) => { + // Same trick for the aggregate datatypes doc: strucpp publishes + // against one URI, but each type renders in its own `.dt` view. + if (params.uri === DATA_TYPES_URI) { + // Replayed whenever a `.dt` model mounts later — the mirror is + // event-driven, so a model created after the last publish would + // otherwise show no markers at all. + lastDataTypeDiagnostics = params.diagnostics + applyDataTypeDiagnostics(ctx.monacoApi, ctx.markerOwner, ctx.defaultSource) + return + } // Mirror VAR-block diagnostics onto the variables-text editor // for the same POU (if mounted). The variables editor uses a // separate Monaco model under `pouvars://.st`; strucpp @@ -194,6 +282,28 @@ export function startStLsp(opts: StLspStartOptions): StLspService { ...(onCrash ? { onCrash } : {}), }) + // A `.dt` view's colours and markers come from the aggregate document, + // so a change there leaves the model's own text untouched and Monaco + // never re-queries on its own. Re-drive both from the store instead. + const dtViewSyncDisposables: Array<() => void> = [] + if (monacoApi) { + const api = monacoApi + dtViewSyncDisposables.push( + openPLCStoreBase.subscribe( + (state) => state.project.data.dataTypes, + () => { + sharedService.refreshSemanticTokens() + applyDataTypeDiagnostics(api, MARKER_OWNER, DIAGNOSTIC_SOURCE) + }, + ), + ) + const onModelAdded = api.editor.onDidCreateModel((model) => { + if (parseDtViewUri(model.uri.toString()) === null) return + applyDataTypeDiagnostics(api, MARKER_OWNER, DIAGNOSTIC_SOURCE) + }) + dtViewSyncDisposables.push(() => onModelAdded.dispose()) + } + // --------------------------------------------------------------------------- // Scoped completion for the graphical (LD/FBD) editors. // @@ -435,6 +545,9 @@ export function startStLsp(opts: StLspStartOptions): StLspService { dispose() { registerScopedQueryApi(null) + for (const off of dtViewSyncDisposables) off() + dtViewSyncDisposables.length = 0 + lastDataTypeDiagnostics = [] sharedService.dispose() }, } diff --git a/src/frontend/services/st-lsp/types.ts b/src/frontend/services/st-lsp/types.ts index ee506b6d5..647804cf7 100644 --- a/src/frontend/services/st-lsp/types.ts +++ b/src/frontend/services/st-lsp/types.ts @@ -31,6 +31,13 @@ export const STUB_URI_AUTHORITY = 'stub' */ export const POUVARS_URI_AUTHORITY = 'pouvars' +/** + * URI scheme for the per-type `.dt` code view. Like `pouvars://`, the + * LSP never indexes it — requests remap onto `DATA_TYPES_URI` with the + * type's line span as the offset. + */ +export const DTVIEW_URI_AUTHORITY = 'dtview' + /** * URI for the synthesized `TYPE…END_TYPE` document carrying every * user-defined `PLCDataType` (structures, enumerations, arrays). @@ -142,6 +149,18 @@ export function parsePouVarsUri(uri: string): string | null { return decodeURIComponent(match[1]) } +/** Make a synthetic in-memory URI for a data type's `.dt` code view. */ +export function dtViewUri(name: string): string { + return `${POU_URI_SCHEME}://${DTVIEW_URI_AUTHORITY}/${encodeURIComponent(name)}.dt` +} + +/** If `uri` is a `dtview://` URI, return the data type name; otherwise null. */ +export function parseDtViewUri(uri: string): string | null { + const match = new RegExp(`^${POU_URI_SCHEME}://${DTVIEW_URI_AUTHORITY}/(.+)\\.dt$`).exec(uri) + if (!match) return null + return decodeURIComponent(match[1]) +} + /** * Number of lines the synthesized declaration occupies before the * VAR blocks. Currently always 1 ("PROGRAM main", "FUNCTION foo : @@ -151,6 +170,13 @@ export function parsePouVarsUri(uri: string): string | null { */ export const POU_DECLARATION_LINE_COUNT = 1 +/** + * Lines the `.dt` code view renders before the type's own declaration — + * its local `TYPE` frame line. The aggregate document has the same + * frame, so a type's shift between the two is `span.start - DT_VIEW_FRAME_LINE_COUNT`. + */ +export const DT_VIEW_FRAME_LINE_COUNT = 1 + /** * Returns the POU name encoded in a URI minted by `pouUri` or * `stubUri`, or `null` if the URI doesn't match one of those diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index 39786825c..7857366bd 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -76,13 +76,15 @@ export type CursorPosition = { * editor. Triggers a forced switch to text mode if the panel * is currently in table mode, and the body editor ignores * positions tagged this way. + * - `data-type` — targets a data type's `.dt` code view, with the + * same forced switch out of table mode. * * Used by Go to Definition redirects: when the LSP points at a * variable declaration (synthesized header line), we surface that * line inside the variables panel instead of clamping the cursor * to the body's line 1. */ - target?: 'body' | 'variables' + target?: 'body' | 'variables' | 'data-type' } // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts index 2010918ed..898e8f847 100644 --- a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts +++ b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts @@ -11,7 +11,12 @@ * one of these up. */ import type { PLCDataType } from '../../../../middleware/shared/ports/types' -import { serializeDataTypesToLines, serializeDataTypesToST, serializeDataTypeToText } from '../data-type-serializer' +import { + dataTypeLineSpans, + serializeDataTypesToLines, + serializeDataTypesToST, + serializeDataTypeToText, +} from '../data-type-serializer' const enumerated = (name: string, values: string[], initialValue?: string): PLCDataType => ({ name, @@ -255,3 +260,50 @@ describe('serializeDataTypesToLines', () => { expect(reconstructed).toBe(flat) }) }) + +describe('dataTypeLineSpans', () => { + it('places entries after the TYPE frame and accumulates multi-line spans', () => { + const spans = dataTypeLineSpans([ + { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' }, + { + name: 'Motor', + derivation: 'structure', + variable: [ + { name: 'speed', type: { definition: 'base-type', value: 'INT' } }, + { name: 'torque', type: { definition: 'base-type', value: 'INT' } }, + ], + }, + { + name: 'Buffer', + derivation: 'array', + dimensions: [{ dimension: '0..9' }], + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '', + }, + ]) + + // Line 0 is `TYPE`; the struct occupies declaration + 2 fields + END_STRUCT. + expect(spans.get('Colors')).toEqual({ start: 1, length: 1 }) + expect(spans.get('Motor')).toEqual({ start: 2, length: 4 }) + expect(spans.get('Buffer')).toEqual({ start: 6, length: 1 }) + }) + + it('agrees with the rendered aggregate document', () => { + const dataTypes: PLCDataType[] = [ + { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' }, + { + name: 'Motor', + derivation: 'structure', + variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }], + }, + ] + const lines = serializeDataTypesToST(dataTypes).split('\n') + for (const [name, span] of dataTypeLineSpans(dataTypes)) { + expect(lines[span.start]).toContain(name) + } + }) + + it('is empty for no data types', () => { + expect(dataTypeLineSpans([]).size).toBe(0) + }) +}) diff --git a/src/frontend/utils/PLC/data-type-serializer.ts b/src/frontend/utils/PLC/data-type-serializer.ts index 26b7c1170..c9ece49c0 100644 --- a/src/frontend/utils/PLC/data-type-serializer.ts +++ b/src/frontend/utils/PLC/data-type-serializer.ts @@ -111,6 +111,33 @@ export function serializeDataTypesToLines(dataTypes: PLCDataType[]): SerializedD return out } +/** Where one data type's lines sit inside the aggregate `TYPE…END_TYPE` block. */ +export interface DataTypeLineSpan { + /** 0-indexed first line of the entry in the aggregate document. */ + start: number + /** Line count of the entry. */ + length: number +} + +/** + * Line spans of every entry in the aggregate document, keyed by name. + * Line 0 is the `TYPE` frame, so entries start at 1. + * + * The per-type `.dt` code view renders the same lines under its own + * `TYPE…END_TYPE` frame, so `start - 1` is the shift between the two + * frames — that is what the LSP layer needs to talk to the aggregate + * document on a per-type buffer's behalf. + */ +export function dataTypeLineSpans(dataTypes: PLCDataType[]): Map { + const spans = new Map() + let start = 1 + for (const entry of serializeDataTypesToLines(dataTypes)) { + spans.set(entry.name, { start, length: entry.lines.length }) + start += entry.lines.length + } + return spans +} + /** * Serialise every entry in `dataTypes` to a single ST `TYPE` block. * Returns `''` when there's nothing to emit — the LSP sync layer From 528c0468c92284c311e7ee8035a1f917bf4a4f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Fri, 7 Aug 2026 17:39:20 -0300 Subject: [PATCH 70/79] fix(st-lsp): stop a malformed synthetic URI from throwing out of the parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parsePouUri`, `parsePouVarsUri` and `parseDtViewUri` decoded their name segment with a bare `decodeURIComponent`, which raises `URIError` on input like `%ZZ`. All three run inside `resolveStLspContext`, on every model URI the providers see, so one malformed URI would take hover, completion and definition down for that model rather than simply not matching. Not reachable today — these URIs are only minted by the matching builders, which encode the name — but the guard belongs in all three rather than in whichever one was touched last. Refs DOPE-537 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GvGrhB1LyJz9MBwYhjoBDY --- .../services/st-lsp/__tests__/types.test.ts | 17 ++++++++++++ src/frontend/services/st-lsp/types.ts | 26 ++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/frontend/services/st-lsp/__tests__/types.test.ts b/src/frontend/services/st-lsp/__tests__/types.test.ts index 65df02511..597dd5fdd 100644 --- a/src/frontend/services/st-lsp/__tests__/types.test.ts +++ b/src/frontend/services/st-lsp/__tests__/types.test.ts @@ -97,3 +97,20 @@ describe('dtViewUri / parseDtViewUri', () => { expect(parsePouVarsUri(dtViewUri('Motor'))).toBeNull() }) }) + +describe('malformed percent encoding', () => { + // These parsers run on every model URI the providers see, so a throw + // here would take hover / completion down for that model. + it('returns null instead of throwing, for every synthetic URI shape', () => { + expect(parsePouUri('inmemory://pou/%ZZ.st')).toBeNull() + expect(parsePouUri('inmemory://stub/%ZZ.st')).toBeNull() + expect(parsePouVarsUri('inmemory://pouvars/%ZZ.st')).toBeNull() + expect(parseDtViewUri('inmemory://dtview/%ZZ.dt')).toBeNull() + }) + + it('still decodes well-formed encodings', () => { + expect(parsePouUri(pouUri('My POU'))?.name).toBe('My POU') + expect(parsePouVarsUri(pouVarsUri('My POU'))).toBe('My POU') + expect(parseDtViewUri(dtViewUri('My Type'))).toBe('My Type') + }) +}) diff --git a/src/frontend/services/st-lsp/types.ts b/src/frontend/services/st-lsp/types.ts index 647804cf7..d667f8372 100644 --- a/src/frontend/services/st-lsp/types.ts +++ b/src/frontend/services/st-lsp/types.ts @@ -146,7 +146,7 @@ export function pouVarsUri(name: string): string { export function parsePouVarsUri(uri: string): string | null { const match = new RegExp(`^${POU_URI_SCHEME}://${POUVARS_URI_AUTHORITY}/(.+)\\.st$`).exec(uri) if (!match) return null - return decodeURIComponent(match[1]) + return decodeUriSegment(match[1]) } /** Make a synthetic in-memory URI for a data type's `.dt` code view. */ @@ -158,7 +158,7 @@ export function dtViewUri(name: string): string { export function parseDtViewUri(uri: string): string | null { const match = new RegExp(`^${POU_URI_SCHEME}://${DTVIEW_URI_AUTHORITY}/(.+)\\.dt$`).exec(uri) if (!match) return null - return decodeURIComponent(match[1]) + return decodeUriSegment(match[1]) } /** @@ -177,6 +177,21 @@ export const POU_DECLARATION_LINE_COUNT = 1 */ export const DT_VIEW_FRAME_LINE_COUNT = 1 +/** + * Decode a name segment out of a synthetic URI, or `null` when the + * encoding is malformed. These parsers run on every model URI the LSP + * providers see, so a bare `decodeURIComponent` would turn a stray + * `%ZZ` into a thrown `URIError` and take hover / completion down with + * it for that model. + */ +function decodeUriSegment(segment: string): string | null { + try { + return decodeURIComponent(segment) + } catch { + return null + } +} + /** * Returns the POU name encoded in a URI minted by `pouUri` or * `stubUri`, or `null` if the URI doesn't match one of those @@ -186,8 +201,7 @@ export const DT_VIEW_FRAME_LINE_COUNT = 1 export function parsePouUri(uri: string): { kind: 'pou' | 'stub'; name: string } | null { const match = new RegExp(`^${POU_URI_SCHEME}://(${POU_URI_AUTHORITY}|${STUB_URI_AUTHORITY})/(.+)\\.st$`).exec(uri) if (!match) return null - return { - kind: match[1] === POU_URI_AUTHORITY ? 'pou' : 'stub', - name: decodeURIComponent(match[2]), - } + const name = decodeUriSegment(match[2]) + if (name === null) return null + return { kind: match[1] === POU_URI_AUTHORITY ? 'pou' : 'stub', name } } From 63ad4b2705a7f9747aa7ee76c08e89f9ebde3d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Fri, 7 Aug 2026 18:50:29 -0300 Subject: [PATCH 71/79] fix(st-lsp): stop an unknown .dt name from resolving as the first type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.dt` view whose name has no entry in the aggregate document fell back to `span?.start ?? 1`, which is the first entry's mapping — so hover, completion and go-to-definition answered for an unrelated type. An unparseable `.dt` file reaches this path with a live, visible buffer. Pass the view's own un-indexed URI through instead: strucpp guards every handler on the document being known, so each provider answers nothing. Move the frame arithmetic into `dtview-context.ts` so it can be tested without a worker, and drop the duplicated shift in the diagnostics fan-out. Gate the store subscription on `isDataTypeFilesEnabled()` and on a `.dt` model actually being mounted. `refreshSemanticTokens()` re-tokenises every model in the ST language, so with the flag off a datatype table edit was triggering a worker round trip per open ST editor. Convert the goto-definition cursor at the call site, next to the POU conversions, rather than half inside the routing helper. Reported by review on #657 / #998. The frame-line aliasing (DOPE-554) and the formatting end-clip (DOPE-555) are tracked separately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GvGrhB1LyJz9MBwYhjoBDY --- .../st-lsp/__tests__/dtview-context.test.ts | 99 +++++++++++++++++++ .../services/st-lsp/dtview-context.ts | 52 ++++++++++ .../st-lsp/goto-definition-redirect.ts | 21 ++-- src/frontend/services/st-lsp/index.ts | 51 ++++------ 4 files changed, 184 insertions(+), 39 deletions(-) create mode 100644 src/frontend/services/st-lsp/__tests__/dtview-context.test.ts create mode 100644 src/frontend/services/st-lsp/dtview-context.ts diff --git a/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts b/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts new file mode 100644 index 000000000..735cd1bbc --- /dev/null +++ b/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts @@ -0,0 +1,99 @@ +/** + * @jest-environment jsdom + */ +import type { Diagnostic } from 'vscode-languageserver-protocol' + +import type { PLCDataType } from '../../../../middleware/shared/ports/types' +import { diagnosticsInSpan, dtViewLineOffset, dtViewSpan, dtViewWindow } from '../dtview-context' + +const enumType = (name: string): PLCDataType => ({ + name, + derivation: 'enumerated', + values: [{ description: 'RED' }], + initialValue: 'RED', +}) + +const structType = (name: string, fields: string[]): PLCDataType => ({ + name, + derivation: 'structure', + variable: fields.map((field) => ({ + name: field, + type: { definition: 'base-type', value: 'INT' }, + })), +}) + +// Aggregate document, LSP (0-indexed) lines: +// 0 TYPE +// 1 Colors : (RED) := RED; +// 2 Motor : STRUCT +// 3 speed : INT; +// 4 END_STRUCT; +// 5 END_TYPE +const DATA_TYPES: PLCDataType[] = [enumType('Colors'), structType('Motor', ['speed'])] + +const diagnosticAt = (line: number): Diagnostic => ({ + range: { start: { line, character: 0 }, end: { line, character: 4 } }, + message: `line ${line}`, +}) + +describe('dtViewSpan', () => { + it('returns the entry span for a type in the document', () => { + expect(dtViewSpan(DATA_TYPES, 'Motor')).toEqual({ start: 2, length: 3 }) + }) + + it('returns null for a name the document has no entry for', () => { + expect(dtViewSpan(DATA_TYPES, 'Missing')).toBeNull() + }) + + it('returns null for every name when the document is empty', () => { + expect(dtViewSpan([], 'Colors')).toBeNull() + }) +}) + +describe('dtViewLineOffset', () => { + it('is zero for the first entry — both frames open with their own TYPE line', () => { + const span = dtViewSpan(DATA_TYPES, 'Colors') + expect(span && dtViewLineOffset(span)).toBe(0) + }) + + it('shifts a later entry by its distance down the document', () => { + const span = dtViewSpan(DATA_TYPES, 'Motor') + expect(span && dtViewLineOffset(span)).toBe(1) + }) + + it('shifts the last entry of a longer document', () => { + const dataTypes = [enumType('A'), enumType('B'), structType('C', ['x', 'y'])] + const span = dtViewSpan(dataTypes, 'C') + expect(span && dtViewLineOffset(span)).toBe(2) + }) +}) + +describe('dtViewWindow', () => { + it('covers the entry lines only, never the frame line above them', () => { + const span = dtViewSpan(DATA_TYPES, 'Motor') + expect(span && dtViewWindow(span)).toEqual({ startLine: 2, endLineExclusive: 5 }) + }) + + it('is a single line for a one-line entry', () => { + const span = dtViewSpan(DATA_TYPES, 'Colors') + expect(span && dtViewWindow(span)).toEqual({ startLine: 1, endLineExclusive: 2 }) + }) +}) + +describe('diagnosticsInSpan', () => { + it('keeps only what falls inside the entry', () => { + const span = dtViewSpan(DATA_TYPES, 'Motor') + const kept = span ? diagnosticsInSpan([diagnosticAt(1), diagnosticAt(3), diagnosticAt(5)], span) : [] + expect(kept.map((d) => d.message)).toEqual(['line 3']) + }) + + it('excludes the line directly above the entry — that belongs to the previous type', () => { + const span = dtViewSpan(DATA_TYPES, 'Motor') + expect(span && diagnosticsInSpan([diagnosticAt(1)], span)).toEqual([]) + }) + + it('returns nothing when the entry is clean', () => { + const span = dtViewSpan(DATA_TYPES, 'Colors') + expect(span && diagnosticsInSpan([diagnosticAt(3)], span)).toEqual([]) + }) +}) diff --git a/src/frontend/services/st-lsp/dtview-context.ts b/src/frontend/services/st-lsp/dtview-context.ts new file mode 100644 index 000000000..22ccfbd10 --- /dev/null +++ b/src/frontend/services/st-lsp/dtview-context.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * Coordinate translation between a `.dt` code view and the aggregate + * datatypes document. + * + * A `.dt` view renders one type under its own `TYPE` frame line, while + * strucpp only ever sees `DATA_TYPES_URI` — every type in one document, + * under one frame. So the two frames differ by the type's position in + * the aggregate, and every request crossing the seam has to be shifted + * by it. + * + * These helpers take `dataTypes` rather than reading the store, so the + * arithmetic is testable on its own — the seam is where the token and + * marker defects in DOPE-537 came from. + */ + +import type { Diagnostic } from 'vscode-languageserver-protocol' + +import type { PLCDataType } from '../../../middleware/shared/ports/types' +import { type DataTypeLineSpan, dataTypeLineSpans } from '../../utils/PLC/data-type-serializer' +import { DT_VIEW_FRAME_LINE_COUNT } from './types' + +/** The aggregate document's line span for `dtName`, or null if it has none. */ +export function dtViewSpan(dataTypes: PLCDataType[], dtName: string): DataTypeLineSpan | null { + return dataTypeLineSpans(dataTypes).get(dtName) ?? null +} + +/** + * Lines to add to a `.dt` view position to reach the aggregate document. + * Both frames open with a `TYPE` line, so the shift is the entry's start + * minus that frame — 0 for the first type. + */ +export function dtViewLineOffset(span: DataTypeLineSpan): number { + return span.start - DT_VIEW_FRAME_LINE_COUNT +} + +/** + * Aggregate-document line window backing the view. Holds the entry's own + * lines only — widening it to cover the view's `TYPE` frame would pull in + * the previous entry's last line, whose columns overrun that 4-character + * frame line. + */ +export function dtViewWindow(span: DataTypeLineSpan): { startLine: number; endLineExclusive: number } { + return { startLine: span.start, endLineExclusive: span.start + span.length } +} + +/** The published diagnostics that fall inside the entry's own lines. */ +export function diagnosticsInSpan(diagnostics: Diagnostic[], span: DataTypeLineSpan): Diagnostic[] { + const { startLine, endLineExclusive } = dtViewWindow(span) + return diagnostics.filter((d) => d.range.start.line >= startLine && d.range.start.line < endLineExclusive) +} diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts index d778456df..675d253fc 100644 --- a/src/frontend/services/st-lsp/goto-definition-redirect.ts +++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts @@ -111,11 +111,11 @@ function openDataTypeEditor(dataType: PLCDataType): boolean { } /** - * Open the type's tab in code mode with the cursor on `lineInEntry` - * (0 = its declaration line). Falls back to the form tab when the - * `.dt` code view isn't built into this release. + * Open the type's tab in code mode at a Monaco position in its `.dt` + * view. Falls back to the form tab when the code view isn't built into + * this release. */ -function routeToDataTypeCodeView(dataType: PLCDataType, lineInEntry: number, characterLsp: number): boolean { +function routeToDataTypeCodeView(dataType: PLCDataType, monacoLine: number, monacoColumn: number): boolean { if (!openDataTypeEditor(dataType)) return false if (!isDataTypeFilesEnabled()) return true const { @@ -123,9 +123,8 @@ function routeToDataTypeCodeView(dataType: PLCDataType, lineInEntry: number, cha } = openPLCStoreBase.getState() updateModelStructureForName(dataType.name, { display: 'code' }) setEditorCursor(dataType.name, { - // The view renders its own `TYPE` frame line before the entry. - lineNumber: lineInEntry + DT_VIEW_FRAME_LINE_COUNT + 1, - column: Math.max(1, characterLsp + 1), + lineNumber: monacoLine, + column: monacoColumn, offset: 0, target: 'data-type', }) @@ -237,7 +236,13 @@ export function redirectDefinitionToStore(loc: Location | LocationLink): boolean const dataTypes = openPLCStoreBase.getState().project.data.dataTypes const hit = findDataTypeAtLine(target.lineLsp, dataTypes) if (!hit) return false - return routeToDataTypeCodeView(hit.dataType, hit.lineInEntry, target.characterLsp) + // Entry-relative line → `.dt` view line (its own `TYPE` frame sits + // above the entry) → Monaco's 1-indexed frame. + return routeToDataTypeCodeView( + hit.dataType, + hit.lineInEntry + DT_VIEW_FRAME_LINE_COUNT + 1, + target.characterLsp + 1, + ) } const parsed = parsePouUri(target.uri) diff --git a/src/frontend/services/st-lsp/index.ts b/src/frontend/services/st-lsp/index.ts index d8a4e2bb3..0c3ba077d 100644 --- a/src/frontend/services/st-lsp/index.ts +++ b/src/frontend/services/st-lsp/index.ts @@ -36,6 +36,7 @@ import { } from 'vscode-languageserver-protocol' import { openPLCStoreBase } from '../../store' +import { isDataTypeFilesEnabled } from '../../utils/feature-flags' import { dataTypeLineSpans, serializeDataTypeToText } from '../../utils/PLC/data-type-serializer' import { serializePouScopeForQuery } from '../../utils/PLC/pou-signature-serializer' import { @@ -47,6 +48,7 @@ import { suppressNoDefinitionFound, } from '../lsp-shared' import { parseScopedCompletionType } from './completion-type' +import { diagnosticsInSpan, dtViewLineOffset, dtViewSpan, dtViewWindow } from './dtview-context' import { redirectDefinitionToStore } from './goto-definition-redirect' import { redirectToGraphicalPou } from './graphical-redirect' import { registerScopedQueryApi, type ScopedCompletionItem } from './scoped-query' @@ -106,28 +108,17 @@ function resolveStLspContext(modelUri: string): LspContext { } const dtName = parseDtViewUri(modelUri) if (dtName !== null) { - const span = dataTypeLineSpans(openPLCStoreBase.getState().project.data.dataTypes).get(dtName) - // An unknown name (type deleted while its tab is open) keeps the - // frame shift so requests stay inside the document. - return { lspUri: DATA_TYPES_URI, lineOffset: (span?.start ?? 1) - DT_VIEW_FRAME_LINE_COUNT } + const span = dtViewSpan(openPLCStoreBase.getState().project.data.dataTypes, dtName) + // A name absent from the document (unparseable `.dt` file) has no + // span to shift by. Pass the view's own URI through: the worker + // never indexed it, so every provider answers nothing rather than + // answering for whichever type happens to be first. + if (!span) return { lspUri: modelUri, lineOffset: 0 } + return { lspUri: DATA_TYPES_URI, lineOffset: dtViewLineOffset(span) } } return { lspUri: modelUri, lineOffset: getBodyLineOffset(modelUri) } } -/** - * Aggregate-document line window backing a `.dt` code view, or null. - * Holds the entry's own lines only — widening it to cover the view's - * `TYPE` frame would pull in the previous entry's last line, whose - * columns overrun that 4-character frame line. - */ -function dtViewWindow(modelUri: string): { start: number; endExclusive: number } | null { - const dtName = parseDtViewUri(modelUri) - if (dtName === null) return null - const span = dataTypeLineSpans(openPLCStoreBase.getState().project.data.dataTypes).get(dtName) - if (!span) return null - return { start: span.start, endExclusive: span.start + span.length } -} - /** * True while a `.dt` model's text still matches what the store would * serialise for that type. An uncommitted edit breaks the match, and @@ -150,14 +141,12 @@ function applyDataTypeDiagnostics(monacoApi: typeof monaco, markerOwner: string, for (const [name, span] of dataTypeLineSpans(openPLCStoreBase.getState().project.data.dataTypes)) { const model = monacoApi.editor.getModels().find((m) => m.uri.toString() === dtViewUri(name)) if (!model) continue - const shift = span.start - DT_VIEW_FRAME_LINE_COUNT - const owned = lastDataTypeDiagnostics.filter( - (d) => d.range.start.line >= span.start && d.range.start.line < span.start + span.length, - ) monacoApi.editor.setModelMarkers( model, markerOwner, - owned.map((d) => lspDiagnosticToMonaco(d, monacoApi, shift, defaultSource)), + diagnosticsInSpan(lastDataTypeDiagnostics, span).map((d) => + lspDiagnosticToMonaco(d, monacoApi, dtViewLineOffset(span), defaultSource), + ), ) } } @@ -217,17 +206,13 @@ export function startStLsp(opts: StLspStartOptions): StLspService { resolveSemanticTokensViewport: (lspUri, modelUri, lineOffset) => { const dtName = parseDtViewUri(modelUri) if (dtName !== null) { - const dtWindow = dtViewWindow(modelUri) + const span = dtViewSpan(openPLCStoreBase.getState().project.data.dataTypes, dtName) // Empty window while the buffer is uncommitted: no colours beats // colours describing the previous text. - if (!dtWindow || !monacoApi || !dtViewMatchesStore(dtName, monacoApi)) { + if (!span || !monacoApi || !dtViewMatchesStore(dtName, monacoApi)) { return { startLine: 0, endLineExclusive: 0 } } - return { - startLine: dtWindow.start, - endLineExclusive: dtWindow.endExclusive, - outputStartLine: DT_VIEW_FRAME_LINE_COUNT, - } + return { ...dtViewWindow(span), outputStartLine: DT_VIEW_FRAME_LINE_COUNT } } const isVarsView = parsePouVarsUri(modelUri) !== null return { @@ -286,12 +271,16 @@ export function startStLsp(opts: StLspStartOptions): StLspService { // so a change there leaves the model's own text untouched and Monaco // never re-queries on its own. Re-drive both from the store instead. const dtViewSyncDisposables: Array<() => void> = [] - if (monacoApi) { + if (monacoApi && isDataTypeFilesEnabled()) { const api = monacoApi + const hasDtViewModel = () => api.editor.getModels().some((m) => parseDtViewUri(m.uri.toString()) !== null) dtViewSyncDisposables.push( openPLCStoreBase.subscribe( (state) => state.project.data.dataTypes, () => { + // `refresh()` re-tokenises every ST model in the language, so it + // must not fire for a datatype edit made with no `.dt` view open. + if (!hasDtViewModel()) return sharedService.refreshSemanticTokens() applyDataTypeDiagnostics(api, MARKER_OWNER, DIAGNOSTIC_SOURCE) }, From 27fbc4a0e6434724a681acf1f8f57f5d7afc52dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 10 Aug 2026 13:13:39 +0200 Subject: [PATCH 72/79] fix(package-manager): gate the build on VPP package integrity (DOPE-539) Signature verification ran at import and at project open, and neither says anything about the package as it exists when a build starts: userData/packages// is plain user-writable disk and the compiler reads it fresh every compile. The window was "project open -> click Compile", entirely user-controlled. What that window is worth: hal.source is C++ linked into the firmware, hal.pluginEntry is C the runtime compiles ON a live PLC, hal.licenseStore is the on-device licence backend, and because capabilities.isLicensable is a manifest field, editing the installed manifest switches the whole licensing flow off - no licence FCs on connect, no activation call, weak license_* defaults linked in. Add PackageManagerModule.verifyBoardPackageIntegrity(boardName): resolves the VPP behind the board, re-runs verifyPackageSignature, reports the package id and reason on failure. No-op for built-in hals.json boards and when REQUIRE_SIGNATURE is false. Called from compileProgram (before any package file is read), compileForDebugger, and again from handleVendorPluginPackaging - that step runs minutes later in wall-clock terms and is what copies vendor code into the PLC bundle, so it re-checks rather than trusting compile entry. There the gate sits outside the catch-all and throws, because packageVppPlugin turns a throw into the errors[] the pipeline bails on; a logged error would upload a bundle with no vendor I/O. Refuses the compile rather than de-listing the package: tearing a directory out from under a build in flight is a worse failure than stopping and saying why. The project-open sweep keeps ownership of removal. This shortens the window to sub-second, it does not close it - the gate hashes the directory and the pipeline reads it again. Verifying the bytes that actually enter the build is DOPE-558, which touches the shared verify-package-signature.ts and so needs a mirror PR on openplc-web. Co-Authored-By: Claude Opus 5 --- .../handle-vendor-plugin-packaging.test.ts | 35 +++ .../editor/compiler/compiler-module.ts | 51 +++- .../verify-board-package-integrity.test.ts | 276 ++++++++++++++++++ src/backend/editor/package-manager/index.ts | 4 +- .../package-manager/package-manager-module.ts | 56 +++- src/backend/editor/package-manager/types.ts | 19 +- 6 files changed, 435 insertions(+), 6 deletions(-) create mode 100644 src/backend/editor/package-manager/__tests__/verify-board-package-integrity.test.ts diff --git a/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts index e44e95fc0..9f78de947 100644 --- a/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts @@ -32,11 +32,20 @@ type FindVppDevice = typeof import('../../../shared/hardware/find-vpp-device') const listInstalled = jest.fn() const getInstalledPackageManifest = jest.fn() +// The build-time integrity gate (DOPE-539). Defaults to "intact" so the +// provisioning tests below exercise the packaging behaviour they are about; the +// refusal case overrides it explicitly. +const verifyBoardPackageIntegrity = jest.fn<{ ok: boolean; packageId?: string; reason?: string }, [string]>(() => ({ + ok: true, +})) jest.mock('../../package-manager', () => ({ + formatPackageIntegrityError: (boardName: string, failure: { packageId: string; reason: string }) => + `Board "${boardName}" is provided by the VPP package "${failure.packageId}", which no longer matches its signature: ${failure.reason}.`, PackageManagerModule: jest.fn().mockImplementation(() => { const port = { listInstalled, getInstalledPackageManifest } return { ...port, + verifyBoardPackageIntegrity, // Board lookup runs through the shared `findVppDeviceByBoardName`, and // the mock runs the real one over these two stubs rather than // re-implementing the search — a stub that resolved boards its own way @@ -159,6 +168,32 @@ describe('handleVendorPluginPackaging — provisioning branch', () => { expect(logs.some((l) => /source file\(s\)/.test(l.message))).toBe(true) }) + it('refuses to copy the payload when the package no longer matches its signature', async () => { + // DOPE-539: this step is re-gated because it runs minutes after the + // compile-entry check, and what it copies is compiled on the live PLC. + // A throw is the contract — `packageVppPlugin` in the platform port turns + // it into the `errors[]` the pipeline bails on, whereas a logged error + // would let the build upload a bundle with no vendor I/O. + verifyBoardPackageIntegrity.mockReturnValueOnce({ + ok: false, + packageId: 'com.openplc.rpi', + reason: 'Tampered file detected: hal/runtime-v4/plugin/rpi_plugin.o', + }) + + await expect( + runFor({ + type: 'runtime-v4-plugin', + pluginType: 'native', + provisioning: 'prebuilt', + pluginEntry: 'hal/runtime-v4/plugin', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + }), + ).rejects.toThrow(/com\.openplc\.rpi/) + + expect(existsSync(join(targetDir, 'vpp_plugin'))).toBe(false) + expect(logs.some((l) => l.level === 'error' && /rpi_plugin\.o/.test(l.message))).toBe(true) + }) + it('copies the payload byte-for-byte (prebuilt object content preserved)', async () => { await runFor({ type: 'runtime-v4-plugin', diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 8ca30ffc5..42d0c97b3 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -120,7 +120,7 @@ import JSZip from 'jszip' import type { PlatformOption } from '../../../middleware/shared/ports/types' import { BoardInfoResolver } from '../../shared/hardware/board-info-resolver' -import { PackageManagerModule } from '../package-manager' +import { formatPackageIntegrityError, PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' import { createDesktopLibraryBuildPort } from './desktop-library-build-port' import { createEditorCompilerPlatformPort } from './editor-compiler-platform-port' @@ -2010,6 +2010,24 @@ class CompilerModule { sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, ): Promise { + // Second gate, deliberately re-run here rather than trusted from + // `compileProgram` (DOPE-539). This step is what copies vendor C into the + // bundle the runtime compiles ON the PLC, and it runs late — transpile, + // strucpp and the v4 bundle compose happen in between, which on a large + // project is minutes of wall clock during which the package directory is + // still writable. Checking again costs one directory hash. + // + // This sits OUTSIDE the catch-all below on purpose. Every other failure in + // this method degrades the build and reports it; this one has to stop it, + // and `packageVppPlugin` in the platform port turns a throw into the + // `errors[]` the pipeline bails on. + const integrity = new PackageManagerModule().verifyBoardPackageIntegrity(boardTarget) + if (!integrity.ok) { + const message = formatPackageIntegrityError(boardTarget, integrity) + handleOutputData(message, 'error') + throw new Error(message) + } + try { const match = new PackageManagerModule().findDeviceByBoardName(boardTarget) @@ -2370,6 +2388,22 @@ class CompilerModule { Record | undefined, ] + // VPP integrity gate (DOPE-539). FIRST, before the manifest is read for + // anything else: from here on this method trusts the package directory for + // the HAL it links, the licence-store backend it injects and every + // capability it branches on. The import check and the project-open sweep + // are both behind us and neither says anything about the package as it + // exists right now. + const boardPackageIntegrity = new PackageManagerModule().verifyBoardPackageIntegrity(boardTarget) + if (!boardPackageIntegrity.ok) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${formatPackageIntegrityError(boardTarget, boardPackageIntegrity)}\nStopping compilation process.`, + }) + _mainProcessPort.close() + return + } + // Resolve board info uniformly across hals.json + installed VPP // packages via the shared `resolveBoardSelection` helper — the // same code path runs on web (no VPP packages installed → falls @@ -2829,6 +2863,21 @@ class CompilerModule { const [projectPath, boardTarget, projectData] = args as [string, string, PLCProjectData] + // Same gate as `compileProgram` (DOPE-539). The debug build is a smaller + // consumer of the package — it resolves the board and its debug spec — but + // it is still a build the user runs against a live device, and letting it + // through on a package the normal compile just refused would only teach + // that the check is avoidable. + const debugPackageIntegrity = new PackageManagerModule().verifyBoardPackageIntegrity(boardTarget) + if (!debugPackageIntegrity.ok) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${formatPackageIntegrityError(boardTarget, debugPackageIntegrity)}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + const debugResolver = await this.#createBoardInfoResolver() const { boardRuntime } = debugResolver.resolve(boardTarget) const normalizedProjectPath = projectPath.replace('project.json', '') diff --git a/src/backend/editor/package-manager/__tests__/verify-board-package-integrity.test.ts b/src/backend/editor/package-manager/__tests__/verify-board-package-integrity.test.ts new file mode 100644 index 000000000..94a555a0f --- /dev/null +++ b/src/backend/editor/package-manager/__tests__/verify-board-package-integrity.test.ts @@ -0,0 +1,276 @@ +import { createHash, sign as cryptoSign } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { app } from 'electron' + +import { canonicalize, SIGNATURE_FILENAME } from '../../../shared/utils/vpp/verify-package-signature' + +// Same transitive-dependency stubs as the sweep suite: winston file transports +// and extract-zip's ESM entry point are irrelevant to a signature check. +jest.mock('electron', () => ({ app: { getPath: jest.fn(() => '/mock/path') } })) +jest.mock('extract-zip', () => ({ __esModule: true, default: jest.fn() })) +jest.mock('../../services/logger-service', () => ({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, +})) + +// Swap the real trusted-key store for a generated test keypair so fixtures can +// be signed with a private key this suite holds. The factory cannot close over +// outer scope, so it generates inline and re-exports the private PEM. +jest.mock('../../../shared/utils/vpp/trusted-keys', () => { + const { generateKeyPairSync } = jest.requireActual('node:crypto') + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + return { + TRUSTED_PACKAGE_KEYS: { 'test-key': publicKey.export({ type: 'spki', format: 'pem' }).toString() }, + __TEST_PRIVATE_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + } +}) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const PRIVATE_PEM: string = (require('../../../shared/utils/vpp/trusted-keys') as { __TEST_PRIVATE_PEM: string }) + .__TEST_PRIVATE_PEM + +import { formatPackageIntegrityError, PackageManagerModule } from '../package-manager-module' + +const KEY_ID = 'test-key' +const BOARD_NAME = 'Test VPP Board' + +const sha256 = (s: string): string => + createHash('sha256') + .update(Uint8Array.from(Buffer.from(s, 'utf-8'))) + .digest('hex') + +/** A manifest the installed-read path accepts, providing one named device. */ +const manifestFor = (packageId: string): string => + JSON.stringify({ + formatVersion: '1.0', + package: { id: packageId, name: 'Test Package', version: '1.0.0' }, + devices: [ + { + id: 'test-device', + name: BOARD_NAME, + target: { type: 'runtime-v4' }, + hal: { pluginEntry: 'plugin/main.c' }, + }, + ], + }) + +interface FixtureOpts { + /** Extra files beyond manifest.json, keyed by package-relative POSIX path. */ + files?: Record + /** Override fields on the signed payload (e.g. a foreign keyId). */ + payloadOverride?: Record + /** Rewrite a file AFTER signing, to simulate a mid-session edit. */ + tamperFile?: { rel: string; content: string } + /** Skip signature.json entirely (a hand-assembled package directory). */ + omitSignature?: boolean +} + +describe('PackageManagerModule.verifyBoardPackageIntegrity', () => { + let userDataDir: string + let packagesDir: string + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'pkg-build-gate-')) + packagesDir = join(userDataDir, 'packages') + ;(app.getPath as jest.Mock).mockReturnValue(userDataDir) + }) + + afterEach(() => { + jest.clearAllMocks() + rmSync(userDataDir, { recursive: true, force: true }) + }) + + /** Write a signed package directory under packagesDir and register it. */ + function installFixture(packageId: string, opts: FixtureOpts = {}): string { + const dir = join(packagesDir, packageId) + const files: Record = { + 'manifest.json': manifestFor(packageId), + 'plugin/main.c': 'int vpp_init(void) { return 0; }', + ...opts.files, + } + + const fileHashes: Record = {} + for (const [rel, content] of Object.entries(files)) { + const full = join(dir, rel) + mkdirSync(dirname(full), { recursive: true }) + writeFileSync(full, content) + fileHashes[rel] = sha256(content) + } + + if (!opts.omitSignature) { + const payload = { + formatVersion: '1.0', + alg: 'ed25519', + keyId: KEY_ID, + packageId, + version: '1.0.0', + signedAt: '2026-06-01T00:00:00.000Z', + files: fileHashes, + ...opts.payloadOverride, + } + const signature = cryptoSign( + null, + Uint8Array.from(Buffer.from(canonicalize(payload), 'utf-8')), + PRIVATE_PEM, + ).toString('base64') + writeFileSync(join(dir, SIGNATURE_FILENAME), JSON.stringify({ ...payload, signature }, null, 2)) + } + + if (opts.tamperFile) { + writeFileSync(join(dir, opts.tamperFile.rel), opts.tamperFile.content) + } + + mkdirSync(packagesDir, { recursive: true }) + writeFileSync( + join(packagesDir, 'registry.json'), + JSON.stringify( + { + formatVersion: '1.0', + packages: { + [packageId]: { + version: '1.0.0', + installedAt: '2026-06-01T00:00:00.000Z', + path: dir, + devices: ['test-device'], + }, + }, + }, + null, + 2, + ), + ) + + return dir + } + + it('passes an untouched signed package — the negative control', () => { + installFixture('com.test.valid') + + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toEqual({ ok: true }) + }) + + it('passes a board no installed package provides (built-in hals.json board)', () => { + installFixture('com.test.valid') + + expect(new PackageManagerModule().verifyBoardPackageIntegrity('Arduino Uno')).toEqual({ ok: true }) + }) + + it('passes when nothing is installed at all', () => { + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toEqual({ ok: true }) + }) + + it('fails when the plugin payload was edited after installation', () => { + // The DOPE-539 scenario for runtime-v4: vendor C that the runtime compiles + // on the PLC, rewritten between project open and build. + installFixture('com.test.tampered', { + tamperFile: { rel: 'plugin/main.c', content: 'int vpp_init(void) { /* injected */ return 0; }' }, + }) + + const result = new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME) + + expect(result).toEqual({ + ok: false, + packageId: 'com.test.tampered', + reason: expect.stringContaining('plugin/main.c'), + }) + }) + + it('fails when the manifest itself was edited after installation', () => { + // The licensing-bypass shape: `capabilities.isLicensable` is a manifest + // field, so an edit here is what the gate has to catch even though the + // board still resolves. + const dir = join(packagesDir, 'com.test.relicensed') + installFixture('com.test.relicensed') + writeFileSync( + join(dir, 'manifest.json'), + JSON.stringify({ + formatVersion: '1.0', + package: { id: 'com.test.relicensed', name: 'Test Package', version: '1.0.0' }, + devices: [ + { + id: 'test-device', + name: BOARD_NAME, + target: { type: 'runtime-v4' }, + hal: { pluginEntry: 'plugin/main.c' }, + capabilities: { isLicensable: false }, + }, + ], + }), + ) + + const result = new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME) + + expect(result).toEqual({ + ok: false, + packageId: 'com.test.relicensed', + reason: expect.stringContaining('manifest.json'), + }) + }) + + it('fails when a file was added to the package after signing', () => { + const dir = installFixture('com.test.injected') + writeFileSync(join(dir, 'extra.c'), 'void backdoor(void) {}') + + const result = new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME) + + expect(result).toMatchObject({ ok: false, packageId: 'com.test.injected' }) + }) + + it('fails when the package carries no signature at all', () => { + installFixture('com.test.unsigned', { omitSignature: true }) + + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toEqual({ + ok: false, + packageId: 'com.test.unsigned', + reason: expect.stringContaining('not signed'), + }) + }) + + it('fails when the signature names a key the editor does not trust', () => { + installFixture('com.test.selfsigned', { payloadOverride: { keyId: 'untrusted-key' } }) + + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toEqual({ + ok: false, + packageId: 'com.test.selfsigned', + reason: expect.stringContaining('untrusted-key'), + }) + }) + + it('fails when the package directory is gone but the registry entry is not', () => { + const dir = installFixture('com.test.ghost') + // Resolve the board while the files still exist, then remove them — the + // registry entry alone must not be enough to build. + rmSync(join(dir, 'plugin'), { recursive: true, force: true }) + + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toMatchObject({ + ok: false, + packageId: 'com.test.ghost', + }) + expect(existsSync(join(dir, 'manifest.json'))).toBe(true) + }) + + it('leaves the package on disk and in the registry — refusing is not removing', () => { + const dir = installFixture('com.test.keep', { omitSignature: true }) + + new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME) + + expect(existsSync(dir)).toBe(true) + expect(new PackageManagerModule().listInstalled().map((p) => p.packageId)).toEqual(['com.test.keep']) + }) +}) + +describe('formatPackageIntegrityError', () => { + it('names the board, the package, the reason and the remedy', () => { + const message = formatPackageIntegrityError('Test VPP Board', { + packageId: 'com.test.tampered', + reason: 'Tampered file detected: plugin/main.c', + }) + + expect(message).toContain('Test VPP Board') + expect(message).toContain('com.test.tampered') + expect(message).toContain('Tampered file detected: plugin/main.c') + expect(message).toContain('Reinstall the package') + }) +}) diff --git a/src/backend/editor/package-manager/index.ts b/src/backend/editor/package-manager/index.ts index bc74408ef..8d88baddf 100644 --- a/src/backend/editor/package-manager/index.ts +++ b/src/backend/editor/package-manager/index.ts @@ -1,2 +1,2 @@ -export { PackageManagerModule } from './package-manager-module' -export type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } from './types' +export { formatPackageIntegrityError, PackageManagerModule } from './package-manager-module' +export type { ImportResult, InstalledPackage, PackageIntegrityResult, PackageManifest, PackageRegistry } from './types' diff --git a/src/backend/editor/package-manager/package-manager-module.ts b/src/backend/editor/package-manager/package-manager-module.ts index 8df5fde12..e53ed46d5 100644 --- a/src/backend/editor/package-manager/package-manager-module.ts +++ b/src/backend/editor/package-manager/package-manager-module.ts @@ -16,7 +16,7 @@ import { TRUSTED_PACKAGE_KEYS } from '../../shared/utils/vpp/trusted-keys' import { verifyPackageSignature } from '../../shared/utils/vpp/verify-package-signature' import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' -import type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } from './types' +import type { ImportResult, InstalledPackage, PackageIntegrityResult, PackageManifest, PackageRegistry } from './types' /** * Enforce cryptographic signature verification on every import. Strict by @@ -202,6 +202,41 @@ class PackageManagerModule { return removed } + /** + * Re-verify the package that provides `boardName`, at the moment a build is + * about to consume it (DOPE-539). + * + * The import check and the project-open sweep both happen strictly BEFORE + * this point, and nothing between them and the compile stops the user from + * editing the installed package: `getInstalledPackageManifest`, the HAL + * source, the licence-store backend and the runtime-v4 plugin payload are + * all read straight off `userData/packages//` when the build runs. So a + * package that passed on open is not evidence about the package being + * compiled — an edit lands in the firmware, or in C the runtime compiles on + * a live PLC, and (because `capabilities.isLicensable` is a manifest field) + * can switch the whole licensing flow off. + * + * This is the gate that actually protects a build, so it fails CLOSED and + * the callers refuse to compile. It deliberately does NOT de-list or delete + * the package the way the open-time sweep does: tearing a directory out from + * under a build in flight is a worse failure than stopping the build and + * saying why. The sweep still owns removal. + * + * A built-in hals.json board has no package behind it and is `ok` — the + * common case, and the reason this costs nothing for most builds. + */ + verifyBoardPackageIntegrity(boardName: string): PackageIntegrityResult { + if (!REQUIRE_SIGNATURE) return { ok: true } + + const match = this.findDeviceByBoardName(boardName) + if (!match) return { ok: true } + + const reason = this.signatureRejectionReason(match.pkg.packageId, match.pkg.path) + if (!reason) return { ok: true } + + return { ok: false, packageId: match.pkg.packageId, reason } + } + /** * Returns null when the installed package recorded at `packagePath` is * genuinely signed by a trusted key, or a short human-readable reason when it @@ -360,4 +395,21 @@ class PackageManagerModule { } } -export { PackageManagerModule } +/** + * The one wording every build-time integrity refusal uses. + * + * Three call sites in the compiler abort on the same condition, and the user + * reads exactly one of the three; they must not each explain it differently. + * The message names the package (what to reinstall), the reason (what is + * wrong) and the remedy, because "signature verification failed" on its own + * reads as an editor bug rather than as "the file on your disk changed". + */ +function formatPackageIntegrityError(boardName: string, failure: { packageId: string; reason: string }): string { + return ( + `Board "${boardName}" is provided by the VPP package "${failure.packageId}", which no longer matches ` + + `its signature: ${failure.reason}. The package files appear to have been modified after installation, ` + + 'so they cannot be trusted for a build. Reinstall the package from a trusted .vpp file and try again.' + ) +} + +export { formatPackageIntegrityError, PackageManagerModule } diff --git a/src/backend/editor/package-manager/types.ts b/src/backend/editor/package-manager/types.ts index 613dfcb00..ff9528a1b 100644 --- a/src/backend/editor/package-manager/types.ts +++ b/src/backend/editor/package-manager/types.ts @@ -15,4 +15,21 @@ type PackageRegistry = { packages: Record> } -export type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } +/** + * Outcome of the build-time integrity gate + * (`PackageManagerModule.verifyBoardPackageIntegrity`). + * + * `ok: true` covers three genuinely different situations that all mean + * "nothing stands in the way of this build": the board is a built-in + * hals.json entry with no package behind it, the package still matches its + * signature, or enforcement is switched off for local development. The + * caller does not need to tell them apart — it either builds or it does not. + * + * The failure arm carries the `packageId` because the message a user can act + * on has to name the package they must reinstall, and `reason` because + * "files are missing" and "tampered file detected: hal/pi.cpp" send them to + * very different places. + */ +type PackageIntegrityResult = { ok: true } | { ok: false; packageId: string; reason: string } + +export type { ImportResult, InstalledPackage, PackageIntegrityResult, PackageManifest, PackageRegistry } From 5b878d3e26af88ffeb3d2571c6f2d83841afe69b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 10:05:20 -0400 Subject: [PATCH 73/79] feat(console): render arduino-cli colour and collapse progress redraws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build output is captured from a pipe and pushed to the console verbatim, so the two terminal control sequences arduino-cli uses were both mishandled. **Carriage returns.** A download progress bar redraws by rewriting one line with `\r`. Each redraw arrived as its own chunk and became its own timestamped entry, so one core install produced hundreds of near-identical lines that pushed the real output out of view: [09:12:34]: ...54.94 MiB / 93.67 MiB [=====>-----] 58.65% [09:12:35]: ...54.94 MiB / 93.67 MiB [=====>-----] 58.65% [09:12:35]: ...57.68 MiB / 93.67 MiB [======>----] 61.58% A chunk is now collapsed to the frame a terminal would leave on screen, and the entry is marked `transient` while the line is still open. The next redraw overwrites it; a trailing newline commits it and the following download starts a fresh line. One live-updating line, as in a terminal. **SGR colour.** arduino-cli colours its compile summary table (bright green headers, yellow platform id, grey paths). The editor suppressed this with `output.no_color` in `arduino-cli.yaml` — inherited from the 2022 Python editor, which added `--no-color` because the raw `ESC[92m` bytes were printed literally. The console parses SGR now, so the suppression is gone. Colour is split off once, at the console slice: `message` always holds clean text and `segments` carries the styling only when there was any. Search, level filters and copy-to-clipboard keep working on `message` untouched — no consumer besides the renderer learns that colour exists, and uncoloured logs (the overwhelming majority) allocate nothing extra. Dead code removed rather than left behind: - `output.no_color` is dropped from `ARDUINO_DATA`, and existing configs are migrated. The config was written once with `{ flag: 'wx' }` and skipped on EEXIST forever after, so every install that ever ran an older build would have kept colour off and made the new renderer unreachable. Reconciliation is narrow and non-destructive: add missing board-manager URLs, drop `no_color`, prune the `output` map only if it is left empty, and never touch anything else. Uses the `yaml` Document API so user comments, ordering and custom indexes survive; an unparseable config is left alone. - `ArduinoCliConfigSchema` / `ArduinoCliConfig` deleted — a zod schema that described the config's `no_color` shape and was imported by nothing. Not a terminal emulator: cursor addressing, scroll regions and erase-in-line are stripped rather than interpreted, because build output never uses them. Tests: 29 new across the parser, the CR state machine, the slice's overwrite rule and the config migration (including the upgrade-with-no_color path). Verified against real captured arduino-cli bytes: 24 CR frames collapse, the summary table maps to green/plain/green/plain/grey, and neither an escape nor a carriage return survives into stored text. Full suite 6354 passing. Paired with openplc-web (shared-core parity). Co-Authored-By: Claude Opus 5 (1M context) --- src/backend/editor/compiler/types.ts | 15 +- .../data/__tests__/arduino-cli-config.test.ts | 112 +++++++++++++ .../user-service/data/arduino-cli-config.ts | 80 +++++++++ .../services/user-service/data/types.ts | 2 - .../editor/services/user-service/index.ts | 34 +++- .../components/_organisms/console/index.tsx | 1 + .../components/_organisms/console/log.tsx | 52 ++++-- .../store/__tests__/console-slice.test.ts | 77 +++++++++ src/frontend/store/slices/console/slice.ts | 30 +++- src/frontend/store/slices/console/types.ts | 13 +- .../utils/__tests__/debugger-session.test.ts | 66 ++++++++ .../utils/__tests__/terminal-output.test.ts | 116 +++++++++++++ src/frontend/utils/debugger-session.ts | 64 +++++-- src/frontend/utils/terminal-output.ts | 157 ++++++++++++++++++ src/middleware/shared/ports/types.ts | 24 +++ 15 files changed, 785 insertions(+), 58 deletions(-) create mode 100644 src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts create mode 100644 src/backend/editor/services/user-service/data/arduino-cli-config.ts create mode 100644 src/frontend/utils/__tests__/terminal-output.test.ts create mode 100644 src/frontend/utils/terminal-output.ts diff --git a/src/backend/editor/compiler/types.ts b/src/backend/editor/compiler/types.ts index 7d2533449..cc5109af6 100644 --- a/src/backend/editor/compiler/types.ts +++ b/src/backend/editor/compiler/types.ts @@ -1,16 +1,5 @@ import { z } from 'zod/v4' -const ArduinoCliConfigSchema = z.object({ - board_manager: z.object({ - additional_urls: z.array(z.string()), - }), - output: z.object({ - no_color: z.boolean(), - }), -}) - -type ArduinoCliConfig = z.infer - const ArduinoCoreControlSchema = z.array(z.record(z.string(), z.string())) type ArduinoCoreControl = z.infer @@ -38,6 +27,6 @@ type ToolchainProperties = { export type { BoardInfo, HalsFile } from '../hardware/types' export { BoardInfoSchema, HalsFileSchema } from '../hardware/types' -export { ArduinoCliConfigSchema, ArduinoCoreControlSchema } +export { ArduinoCoreControlSchema } -export type { ArduinoCliConfig, ArduinoCoreControl, ToolchainProperties } +export type { ArduinoCoreControl, ToolchainProperties } diff --git a/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts b/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts new file mode 100644 index 000000000..71afbe25e --- /dev/null +++ b/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts @@ -0,0 +1,112 @@ +import { parse } from 'yaml' + +import { reconcileArduinoCliConfig } from '../arduino-cli-config' +import { ARDUINO_DATA } from '../types' + +/** + * The config every install created before this change: two board-manager URLs + * and the colour suppression the console no longer needs. + */ +const LEGACY_CONFIG = ` +board_manager: + additional_urls: + - https://arduino.esp8266.com/stable/package_esp8266com_index.json + - https://espressif.github.io/arduino-esp32/package_esp32_index.json +output: + no_color: true +` + +function urlsOf(yaml: string): string[] { + return (parse(yaml) as { board_manager?: { additional_urls?: string[] } })?.board_manager?.additional_urls ?? [] +} + +describe('reconcileArduinoCliConfig', () => { + // --------------------------------------------------------------------- + // The upgrade path that matters: an install that already has no_color. + // --------------------------------------------------------------------- + it('drops output.no_color from a legacy config', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) + expect(updated).not.toBeNull() + expect(updated).not.toContain('no_color') + // The whole `output` map existed only to hold it. + expect(updated).not.toContain('output:') + }) + + it('backfills the board manager URLs the legacy config never received', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) + const result = urlsOf(updated as string) + for (const url of urlsOf(ARDUINO_DATA)) expect(result).toContain(url) + }) + + it('produces a config that still parses as valid YAML', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) as string + expect(() => parse(updated)).not.toThrow() + expect(parse(updated)).toMatchObject({ board_manager: { additional_urls: expect.any(Array) } }) + }) + + it('is idempotent — a second pass reports nothing left to do', () => { + const once = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) as string + expect(reconcileArduinoCliConfig(once, ARDUINO_DATA)).toBeNull() + }) + + // --------------------------------------------------------------------- + // Don't destroy what the user put there. + // --------------------------------------------------------------------- + it('keeps user-added URLs that the editor does not ship', () => { + const withCustom = ` +board_manager: + additional_urls: + - https://arduino.esp8266.com/stable/package_esp8266com_index.json + - https://example.com/package_mine_index.json +output: + no_color: true +` + const updated = reconcileArduinoCliConfig(withCustom, ARDUINO_DATA) as string + const result = urlsOf(updated) + expect(result).toContain('https://example.com/package_mine_index.json') + // ...and the shipped ones still get added alongside it. + for (const url of urlsOf(ARDUINO_DATA)) expect(result).toContain(url) + }) + + it('preserves unrelated settings and comments', () => { + const withExtras = `# my notes\nlogging:\n level: debug\n${LEGACY_CONFIG}` + const updated = reconcileArduinoCliConfig(withExtras, ARDUINO_DATA) as string + expect(updated).toContain('# my notes') + expect(parse(updated)).toMatchObject({ logging: { level: 'debug' } }) + }) + + it('keeps an output map that still holds other keys', () => { + const withOtherOutput = `board_manager:\n additional_urls: []\noutput:\n no_color: true\n format: json\n` + const updated = reconcileArduinoCliConfig(withOtherOutput, ARDUINO_DATA) as string + expect(updated).not.toContain('no_color') + expect(parse(updated)).toMatchObject({ output: { format: 'json' } }) + }) + + // --------------------------------------------------------------------- + // No-op and failure cases. + // --------------------------------------------------------------------- + it('returns null when the file already matches what we ship', () => { + expect(reconcileArduinoCliConfig(ARDUINO_DATA, ARDUINO_DATA)).toBeNull() + }) + + it('installs the shipped URL list when the key is missing entirely', () => { + const updated = reconcileArduinoCliConfig('output:\n no_color: true\n', ARDUINO_DATA) as string + expect(urlsOf(updated)).toEqual(urlsOf(ARDUINO_DATA)) + }) + + it('leaves an unparseable config alone rather than clobbering it', () => { + expect(reconcileArduinoCliConfig('board_manager: [oops\n : :\n', ARDUINO_DATA)).toBeNull() + }) +}) + +describe('ARDUINO_DATA', () => { + it('no longer ships the obsolete colour suppression', () => { + // The console renders SGR colour now; forcing it off would make the + // renderer dead code on every fresh install. + expect(ARDUINO_DATA).not.toContain('no_color') + }) + + it('is valid YAML with a non-empty board manager list', () => { + expect(urlsOf(ARDUINO_DATA).length).toBeGreaterThan(0) + }) +}) diff --git a/src/backend/editor/services/user-service/data/arduino-cli-config.ts b/src/backend/editor/services/user-service/data/arduino-cli-config.ts new file mode 100644 index 000000000..9ae3299de --- /dev/null +++ b/src/backend/editor/services/user-service/data/arduino-cli-config.ts @@ -0,0 +1,80 @@ +/** + * Reconcile an existing `arduino-cli.yaml` with the one the editor ships. + * + * The config used to be written once with `{ flag: 'wx' }` and skipped + * forever after, so anything added to `ARDUINO_DATA` later never reached an + * existing install — the only fix was deleting the file by hand. This brings + * a stale file up to date in place. + * + * Two rules, and deliberately only two: + * + * - **Add missing board-manager URLs.** Never remove one: users add their own + * vendor indexes here, and VPP-declared indexes arrive at compile time. + * - **Drop `output.no_color`.** The editor forced it on to stop raw `ESC[92m` + * bytes appearing in the console. The console now renders SGR colour + * itself, so the suppression is obsolete; leaving it behind would silently + * keep colour off on every machine that has ever launched an older build. + * + * Everything else is left exactly as the user left it, comments and ordering + * included — hence the `Document` API for the existing file rather than a + * parse/serialise round-trip through plain objects. + */ + +import { isMap, isSeq, parse, parseDocument } from 'yaml' + +const BOARD_MANAGER_URLS_PATH = ['board_manager', 'additional_urls'] +const NO_COLOR_PATH = ['output', 'no_color'] + +type ArduinoCliConfigShape = { + board_manager?: { additional_urls?: unknown } +} + +/** Board-manager URLs declared by the shipped template (which we author). */ +function shippedBoardManagerUrls(shipped: string): string[] { + const urls = (parse(shipped) as ArduinoCliConfigShape | null)?.board_manager?.additional_urls + return Array.isArray(urls) ? urls.filter((url): url is string => typeof url === 'string') : [] +} + +/** + * Return the updated file contents, or `null` when nothing needed changing. + * + * Also returns `null` when `existing` is unparseable — a broken config is the + * user's to fix, and rewriting it would discard whatever they were editing. + */ +export function reconcileArduinoCliConfig(existing: string, shipped: string): string | null { + const doc = parseDocument(existing) + if (doc.errors.length > 0) return null + + let changed = false + + // 1. Board-manager URLs — union, never subtract. + const shippedUrls = shippedBoardManagerUrls(shipped) + if (shippedUrls.length > 0) { + const current = doc.getIn(BOARD_MANAGER_URLS_PATH) + const present = new Set(isSeq(current) ? current.toJSON().map(String) : []) + const missing = shippedUrls.filter((url) => !present.has(url)) + + if (missing.length > 0) { + if (isSeq(current)) { + for (const url of missing) current.add(url) + } else { + // No `additional_urls` key, or it is not a list — install the shipped + // set wholesale rather than guessing at a merge. + doc.setIn(BOARD_MANAGER_URLS_PATH, shippedUrls) + } + changed = true + } + } + + // 2. Retire the obsolete colour suppression. + if (doc.hasIn(NO_COLOR_PATH)) { + doc.deleteIn(NO_COLOR_PATH) + changed = true + + // Don't leave an empty `output:` behind once its only key is gone. + const output = doc.get('output') + if (isMap(output) && output.items.length === 0) doc.delete('output') + } + + return changed ? String(doc) : null +} diff --git a/src/backend/editor/services/user-service/data/types.ts b/src/backend/editor/services/user-service/data/types.ts index 57575b7dc..5d2c6dd8e 100644 --- a/src/backend/editor/services/user-service/data/types.ts +++ b/src/backend/editor/services/user-service/data/types.ts @@ -11,8 +11,6 @@ board_manager: - https://raw.githubusercontent.com/VEA-SRL/IRUINO_Library/main/package_vea_index.json - https://github.com/CONTROLLINO-PLC/controllino_rp2/releases/download/global/package_controllino_rp2_index.json - https://downloads.arduino.cc/packages/package_zephyr_index.json -output: - no_color: true ` export const HISTORY_DATA = { diff --git a/src/backend/editor/services/user-service/index.ts b/src/backend/editor/services/user-service/index.ts index 5e6aae3c3..9f470508a 100644 --- a/src/backend/editor/services/user-service/index.ts +++ b/src/backend/editor/services/user-service/index.ts @@ -1,10 +1,11 @@ import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { exec } from 'child_process' import { app } from 'electron' -import { access, constants, mkdir, rename, rm, writeFile } from 'fs/promises' +import { access, constants, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' import { basename, join } from 'path' import { promisify } from 'util' +import { reconcileArduinoCliConfig } from './data/arduino-cli-config' import { ARDUINO_DATA, HISTORY_DATA, SETTINGS_DATA } from './data/types' import type { ArduinoListOutput } from './types' @@ -144,22 +145,39 @@ class UserService { } /** - * Checks if the Arduino CLI configuration file exists and creates it if it doesn't. + * Create the Arduino CLI configuration file, or bring an existing one up to + * date with what the editor ships. + * + * Previously this wrote with `{ flag: 'wx' }` and swallowed `EEXIST`, so the + * file was effectively write-once. Any install that had launched an older + * build kept a stale config forever — including the now-obsolete + * `output.no_color`, which would keep the console monochrome even though it + * renders SGR colour itself now. See `reconcileArduinoCliConfig` for the + * (deliberately narrow) merge rules. */ async #checkIfArduinoCliConfigExists(): Promise { const pathToArduinoCliConfig = join(app.getPath('userData'), 'User', 'arduino-cli.yaml') + try { await writeFile(pathToArduinoCliConfig, UserService.ARDUINO_FILE_CONTENT, { flag: 'wx' }) + return } catch (err) { - // If the error is due to the file already existing, log a warning and continue. - if (err instanceof Error && err.message.includes('EEXIST')) { - console.warn(`File already exists at ${pathToArduinoCliConfig}.\nSkipping creation.`) - } else if (err instanceof Error) { - console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) - } else { + if (!(err instanceof Error && err.message.includes('EEXIST'))) { console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + return } } + + try { + const existing = await readFile(pathToArduinoCliConfig, 'utf-8') + const updated = reconcileArduinoCliConfig(existing, UserService.ARDUINO_FILE_CONTENT) + if (!updated) return + + await writeFile(pathToArduinoCliConfig, updated, 'utf-8') + console.warn(`Updated Arduino CLI config at ${pathToArduinoCliConfig}.`) + } catch (err) { + console.error(`Error updating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + } } async #executeArduinoCliCommand(command: string): Promise<{ stderr: string; stdout: string }> { diff --git a/src/frontend/components/_organisms/console/index.tsx b/src/frontend/components/_organisms/console/index.tsx index a6bcd49e9..76f45ca58 100644 --- a/src/frontend/components/_organisms/console/index.tsx +++ b/src/frontend/components/_organisms/console/index.tsx @@ -136,6 +136,7 @@ const Console = memo(() => { message={log.message} tstamp={formatTimestamp(log.tstamp ?? new Date(), filters.timestampFormat)} searchTerm={filters.searchTerm} + segments={log.segments} compileError={log.compileError} onCompileErrorClick={navigateToCompileError} /> diff --git a/src/frontend/components/_organisms/console/log.tsx b/src/frontend/components/_organisms/console/log.tsx index 66e2872e2..e0912705c 100644 --- a/src/frontend/components/_organisms/console/log.tsx +++ b/src/frontend/components/_organisms/console/log.tsx @@ -1,4 +1,4 @@ -import type { StructuredCompileError } from '@root/middleware/shared/ports/types' +import type { LogSegment, StructuredCompileError } from '@root/middleware/shared/ports/types' import { Copy } from 'lucide-react' import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react' @@ -18,6 +18,9 @@ type LogComponentProps = ComponentPropsWithoutRef<'p'> & { message: string tstamp: string searchTerm?: string + /** Styled runs when the tool emitted SGR colour; `message` is the same + * text with the escapes stripped. */ + segments?: LogSegment[] /** When set, the bracketed POU prefix on the first line becomes a * click-to-open button that calls {@link onCompileErrorClick}. * Multi-line gcc-style snippet renders as plain pre-wrapped text @@ -63,6 +66,35 @@ const HighlightedText = ({ text, searchTerm }: { text: string; searchTerm?: stri return <>{parts} } +/** + * The message body of a log line. + * + * Applies the tool's SGR colour when the line carried any, and otherwise + * renders exactly as before. Search highlighting runs inside each styled run + * so a match spanning a colour change still highlights correctly. + */ +const MessageBody = ({ + message, + segments, + searchTerm, +}: { + message: string + segments?: LogSegment[] + searchTerm?: string +}) => { + if (!segments?.length) return + + return ( + <> + {segments.map((segment, index) => ( + + + + ))} + + ) +} + /** * Render a compile-error log line: the first line (the bracketed POU * prefix the compiler-module emits, e.g. `[Manual_Override / body @@ -118,6 +150,7 @@ const LogComponent = ({ message, tstamp, searchTerm, + segments, compileError, onCompileErrorClick, ...rest @@ -156,22 +189,13 @@ const LogComponent = ({ {message && (

- {level && tstamp ? ( + {level && tstamp && ( <> [ ]:{' '} - {compileError ? ( - - ) : ( - - )} - ) : compileError ? ( + )} + {compileError ? ( ) : ( - + )}